diff --git a/1.11_Market_Update.md b/1.11_Market_Update.md new file mode 100644 index 000000000..304965950 --- /dev/null +++ b/1.11_Market_Update.md @@ -0,0 +1,834 @@ +# CustomNPC+ 1.11 Market Update - Implementation Plan + +## Overview + +The 1.11 Market Update introduces a comprehensive economy system for CustomNPC+: + +1. **Currency System** - Built-in CNPC+ currency with optional Vault integration +2. **Trader Enhancements** - Stock system, currency-based trades, reset timers +3. **Auction House** - Global auction system with bidding, buyout, and claims + +--- + +## 1. Currency System + +### Status: IMPLEMENTED + +The currency system provides a unified economy for CNPC+ features. + +### 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 + +### Key Files +| File | Purpose | +|------|---------| +| `PlayerCurrencyData.java` | Per-player currency storage, Vault delegation | +| `IPlayerCurrencyData.java` | API interface for currency operations | +| `ConfigMarket.java` | Currency configuration (UseVault, CurrencyName, StartingBalance, MaxBalance) | +| `VaultUtil.java` | Vault API integration via reflection | + +### Configuration (ConfigMarket.java) +``` +Market.Currency: + - Use Vault: false + - Currency Name: "Coins" + - Starting Balance: 0 + - Max Balance: 2147483647 +``` + +### Scripting API (IPlayer) +```javascript +player.getCurrencyBalance() // Get balance +player.setCurrencyBalance(amount) // Set balance +player.depositCurrency(amount) // Add currency (returns success) +player.withdrawCurrency(amount) // Remove currency (returns success) +player.canAffordCurrency(amount) // Check if can afford +player.isUsingVaultCurrency() // Check if Vault is active +player.getFormattedCurrencyBalance() // Get formatted string +``` + +--- + +## 2. Trader Enhancements + +### Status: COMPLETE + +--- + +### 2.1 Stock System - IMPLEMENTED + +Traders can have limited stock that resets on configurable schedules. + +#### Stock Modes +| Mode | Description | Status | +|------|-------------|--------| +| **Global** | All players share the same stock pool | ✅ Working | +| **Per-Player** | Each player has their own stock counts | ✅ Working | + +#### Stock Reset Types (EnumStockReset) +| Type | Description | Status | +|------|-------------|--------| +| `NONE` | Stock never resets | ✅ Working | +| `MCDAILY` | Resets every MC day (24000 ticks) | ✅ Working | +| `MCWEEKLY` | Resets every 7 MC days (168000 ticks) | ✅ Working | +| `MCCUSTOM` | Custom MC tick interval | ✅ Working | +| `RLDAILY` | Resets every 24 real-life hours | ✅ Working | +| `RLWEEKLY` | Resets every 7 real-life days | ✅ Working | +| `RLCUSTOM` | Custom real-time interval | ✅ Working | + +#### Stock UI Features - IMPLEMENTED +| Feature | Status | Notes | +|---------|--------|-------| +| Stock count per item | ✅ | Shows to right of output slot | +| Out of stock indicator | ✅ | Dark overlay + red count | +| Reset timer countdown | ✅ | Real-time client-side countdown in top-right | +| "Reopen Trader" message | ✅ | Shows when countdown reaches 0 | +| Admin stock configuration | ✅ | SubGuiNpcTraderStock | +| Per-slot stock limits | ✅ | SubGuiNpcTraderStockSlots | +| 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 + +Trades can require BOTH items AND currency (additive costs). + +#### Trade Entry Structure +Each trade slot can have: +- **Sold Item** - What the player receives ✅ +- **Currency 1** - First item cost (existing) ✅ +- **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 ✅ +- 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 + +#### Display Features +| Element | Position | Status | +|---------|----------|--------| +| "Trader" label | Top-left | ✅ | +| Reset countdown | Top-right | ✅ Real-time ticking | +| Stock count | Right of each trade | ✅ Green/Red | +| 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 - IMPLEMENTED + +#### 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) +``` + +--- + +## 3. Auction House System + +### Status: NOT STARTED (Fresh implementation) + +### 3.1 Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AuctionController │ +│ - Manages all auction data │ +│ - Handles listing/bidding/buyout logic │ +│ - Processes claims │ +│ - Saves/loads auction data │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ AuctionNotification │ +│ - Tracks pending notifications per player │ +│ - Sends notifications on login │ +│ - Types: Won, Outbid, Sold, Expired, Claimed │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 3.2 GUI Structure + +``` +GuiAuctionHouse (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 + │ + ├── GuiAuctionSell (Create new listing) + │ - Item slot for item to sell + │ - Starting price input + │ - Buyout price input (0 = no buyout) + │ - Listing fee display + │ - Create button + │ + ├── GuiAuctionClaims (My Auctions / Claims) + │ - Active listings tab + │ - Pending claims tab (items + currency to collect) + │ - Cancel listing button + │ - Claim all button + │ + └── GuiAuctionFilter (Filter settings) + - Search text + - Sort options (EnumAuctionSort) + - Price range (min/max) + - Show my listings only toggle +``` + +### 3.3 Admin GUI + +``` +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 +``` + +### 3.4 Data Structures + +#### AuctionListing +```java +public class AuctionListing { + int id; // Unique listing ID + UUID sellerUUID; // Seller's UUID + String sellerName; // Seller's display name + ItemStack item; // Item being sold + long startingPrice; // Minimum starting bid + long buyoutPrice; // Instant buy price (0 = no buyout) + long currentBid; // Current highest bid + UUID highBidderUUID; // Current high bidder (null if no bids) + String highBidderName; // High bidder display name + int bidCount; // Number of bids placed + long createdTime; // When listing was created + long endTime; // When auction ends + EnumAuctionStatus status; // ACTIVE, ENDED, CANCELLED, CLAIMED +} +``` + +#### AuctionClaim +```java +public class AuctionClaim { + int id; // Unique claim ID + UUID playerUUID; // Player who can claim + int 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) + long createdTime; // When claim was created + boolean claimed; // Whether claimed +} +``` + +#### 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 +} +``` + +#### Enums +```java +public enum EnumAuctionStatus { + ACTIVE, // Auction is live + ENDED, // Auction ended (winner determined) + CANCELLED, // Cancelled by seller or admin + CLAIMED // All claims processed +} + +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 +} + +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 + +When a bid is placed with less than `SnipeProtectionMinutes` remaining: +- Extend the auction end time TO `SnipeProtectionMinutes` from now +- Never extend beyond `SnipeProtectionMinutes` +- Default: 2 minutes + +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 + +### ConfigMarket.java Structure + +```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 +``` + +--- + +## 5. File Structure + +### New Files to Create + +``` +constants/ + EnumAuctionStatus.java + EnumAuctionSort.java + EnumClaimType.java + EnumNotificationType.java + +controllers/ + AuctionController.java + AuctionNotification.java + +controllers/data/ + AuctionListing.java + AuctionClaim.java + AuctionFilter.java + +roles/ + RoleAuctioneer.java + +containers/ + ContainerAuction.java + +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) + +client/gui/global/ + GuiManageAuction.java (Admin management) + +network/packets/ + AuctionDataPacket.java (Server → Client data sync) + AuctionActionPacket.java (Client → Server actions) +``` + +### Files to Modify + +``` +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 +``` + +--- + +## 6. Data Storage + +### Auction Data File +Location: `customnpcs/auction.dat` + +Structure: +``` +NBTTagCompound root + ├── NextListingId: int + ├── NextClaimId: int + ├── Listings: NBTTagList + │ └── [AuctionListing NBT...] + ├── Claims: NBTTagList + │ └── [AuctionClaim NBT...] + └── PendingNotifications: NBTTagList + └── [AuctionNotification NBT...] +``` + +--- + +## 7. Lang Keys + +```properties +# Auction House +auction.title=Auction House +auction.browse=Browse +auction.sell=Sell +auction.claims=My Auctions +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 + +# 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 +``` + +--- + +## 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 + +| 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 | + +--- + +## 10. Future Considerations (Not in 1.11) + +- Category-based filtering +- Advanced blacklist system (items, mods, NBT patterns) +- Auction history/statistics +- Command-based auction access +- Auction house tax sink +- Reserve prices +- Private auctions diff --git a/docs/CNPC_1.11_MARKET_UPDATE_PLAN.md b/docs/CNPC_1.11_MARKET_UPDATE_PLAN.md deleted file mode 100644 index fb1c33158..000000000 --- a/docs/CNPC_1.11_MARKET_UPDATE_PLAN.md +++ /dev/null @@ -1,1871 +0,0 @@ -# CustomNPC+ 1.11 Market Update - Implementation Plan - -## Overview - -This document outlines the implementation plan for the CustomNPC+ 1.11 update, focusing on three major features: - -1. **Auction House** - A new NPC role allowing players to access a global auction system -2. **Currency System** - Built-in currency integration with VaultAPI for Bukkit hybrid servers -3. **Trader Enhancements** - Stock/Inventory system with per-player/per-server options and reset timers - ---- - -## Table of Contents - -1. [Architecture Overview](#architecture-overview) -2. [Feature 1: Currency System](#feature-1-currency-system) -3. [Feature 2: Trader Enhancements](#feature-2-trader-enhancements) -4. [Feature 3: Auction House](#feature-3-auction-house) -5. [Implementation Order](#implementation-order) -6. [File Summary](#file-summary) -7. [Testing Strategy](#testing-strategy) - ---- - -## Architecture Overview - -### Current Codebase Patterns - -The implementation will follow existing patterns found in the codebase: - -| Pattern | Used By | Will Be Applied To | -|---------|---------|-------------------| -| Singleton Controller | `BankController`, `QuestController` | `AuctionController`, `CurrencyController` | -| Role Interface | `RoleTrader`, `RoleBank` | `RoleAuctioneer` | -| Player Data Extension | `PlayerBankData`, `PlayerQuestData` | `PlayerAuctionData`, `PlayerCurrencyData` | -| Cooldown System | `Quest.repeat`, `JobItemGiver` | Trader Stock Reset | -| Optional Addon | `PixelmonHelper`, `DBCAddon` | `VaultHelper` | -| NBT Serialization | All data classes | All new data classes | -| Cache System | `Market.java`, `CacheHashMap` | Auction listings cache | - -### Package Structure for New Features - -``` -noppes/npcs/ -├── controllers/ -│ ├── AuctionController.java # Manages global auction house -│ ├── CurrencyController.java # Manages currency settings -│ └── data/ -│ ├── AuctionListing.java # Single auction listing -│ ├── AuctionData.java # World auction data -│ ├── PlayerAuctionData.java # Per-player auction history -│ ├── PlayerCurrencyData.java # Per-player currency balance -│ └── TraderStock.java # Trader stock configuration -├── roles/ -│ └── RoleAuctioneer.java # Auctioneer NPC role -├── containers/ -│ ├── ContainerAuction*.java # Auction GUI containers -│ └── ContainerNPCTraderStock.java # Stock management container -├── client/gui/ -│ ├── player/ -│ │ └── GuiAuction*.java # Player-facing auction GUIs -│ └── roles/ -│ └── GuiNpcAuctioneerSetup.java # Auctioneer configuration GUI - -kamkeel/npcs/ -├── controllers/ -│ └── CurrencyController.java # Currency management -├── network/packets/ -│ ├── data/ -│ │ ├── AuctionSyncPacket.java # Sync auction data -│ │ └── CurrencySyncPacket.java # Sync currency data -│ └── player/ -│ ├── AuctionActionPacket.java # Auction actions (bid, buy, sell) -│ └── CurrencyActionPacket.java # Currency transactions -└── compat/ - └── VaultHelper.java # VaultAPI integration -``` - ---- - -## Feature 1: Currency System - -### 1.1 Overview - -The currency system provides a unified economy layer that can: -- Use VaultAPI when available on Bukkit hybrid servers (Cauldron, Thermos, Mohist) -- Fall back to an internal item-based currency when VaultAPI is unavailable -- Be configured per-world or globally - -### 1.2 VaultAPI Integration - -#### 1.2.1 Dependency Setup - -**File: `dependencies.gradle`** -```gradle -// VaultAPI - compile-only for optional integration -compileOnly("net.milkbowl.vault:VaultAPI:1.4") { - transitive = false -} -``` - -**File: `repositories.gradle`** -```gradle -maven { - name = "vault-repo" - url = "https://nexus.hc.to/content/repositories/pub_releases/" -} -``` - -> **Note:** VaultAPI 1.4.1 works with 1.7.10 Bukkit hybrid servers. The dependency is compile-only so the mod works without Vault present. - -#### 1.2.2 VaultHelper Class - -**File: `src/main/java/kamkeel/npcs/compat/VaultHelper.java`** - -```java -package kamkeel.npcs.compat; - -import cpw.mods.fml.common.Loader; -import net.minecraft.entity.player.EntityPlayer; -import noppes.npcs.LogWriter; -import org.bukkit.Bukkit; -import org.bukkit.plugin.RegisteredServiceProvider; - -public class VaultHelper { - public static boolean Enabled = false; - public static boolean Available = false; - - // Cached via reflection - private static Object economy = null; - private static Class economyClass = null; - private static java.lang.reflect.Method getBalance; - private static java.lang.reflect.Method withdrawPlayer; - private static java.lang.reflect.Method depositPlayer; - private static java.lang.reflect.Method hasMethod; - private static java.lang.reflect.Method format; - - public static void load() { - // Check if we're on a Bukkit hybrid server - try { - Class.forName("org.bukkit.Bukkit"); - Available = true; - } catch (ClassNotFoundException e) { - Available = false; - return; - } - - // Try to hook into Vault - try { - Class vaultClass = Class.forName("net.milkbowl.vault.economy.Economy"); - economyClass = vaultClass; - - RegisteredServiceProvider rsp = Bukkit.getServicesManager() - .getRegistration(vaultClass); - - if (rsp != null) { - economy = rsp.getProvider(); - - // Cache reflection methods - getBalance = economyClass.getMethod("getBalance", String.class); - withdrawPlayer = economyClass.getMethod("withdrawPlayer", - String.class, double.class); - depositPlayer = economyClass.getMethod("depositPlayer", - String.class, double.class); - hasMethod = economyClass.getMethod("has", String.class, double.class); - format = economyClass.getMethod("format", double.class); - - Enabled = true; - LogWriter.info("VaultAPI Economy hooked successfully!"); - } - } catch (Exception e) { - LogWriter.info("VaultAPI not available: " + e.getMessage()); - Enabled = false; - } - } - - // Economy Methods - public static double getBalance(EntityPlayer player) { - if (!Enabled) return 0.0; - try { - return (Double) getBalance.invoke(economy, player.getCommandSenderName()); - } catch (Exception e) { - LogWriter.except(e); - return 0.0; - } - } - - public static boolean withdraw(EntityPlayer player, double amount) { - if (!Enabled) return false; - try { - Object response = withdrawPlayer.invoke(economy, - player.getCommandSenderName(), amount); - return (Boolean) response.getClass() - .getMethod("transactionSuccess").invoke(response); - } catch (Exception e) { - LogWriter.except(e); - return false; - } - } - - public static boolean deposit(EntityPlayer player, double amount) { - if (!Enabled) return false; - try { - Object response = depositPlayer.invoke(economy, - player.getCommandSenderName(), amount); - return (Boolean) response.getClass() - .getMethod("transactionSuccess").invoke(response); - } catch (Exception e) { - LogWriter.except(e); - return false; - } - } - - public static boolean has(EntityPlayer player, double amount) { - if (!Enabled) return false; - try { - return (Boolean) hasMethod.invoke(economy, - player.getCommandSenderName(), amount); - } catch (Exception e) { - LogWriter.except(e); - return false; - } - } - - public static String formatCurrency(double amount) { - if (!Enabled) return String.format("%.2f", amount); - try { - return (String) format.invoke(economy, amount); - } catch (Exception e) { - return String.format("%.2f", amount); - } - } -} -``` - -#### 1.2.3 Initialization Hook - -**File: `src/main/java/noppes/npcs/CustomNpcs.java`** - -Add to `load()` method after AddonManager: -```java -// Initialize VaultAPI integration (for Bukkit hybrid servers) -VaultHelper.load(); -``` - -### 1.3 Currency Controller - -**File: `src/main/java/kamkeel/npcs/controllers/CurrencyController.java`** - -```java -package kamkeel.npcs.controllers; - -import net.minecraft.entity.player.EntityPlayer; -import net.minecraft.item.ItemStack; -import net.minecraft.nbt.NBTTagCompound; -import kamkeel.npcs.compat.VaultHelper; -import noppes.npcs.NoppesUtilPlayer; - -public class CurrencyController { - private static CurrencyController instance; - - // Configuration - public boolean useVaultIfAvailable = true; - public ItemStack fallbackCurrency = null; // Item to use when Vault unavailable - public double itemToVaultRatio = 1.0; // 1 item = X vault currency - - public static CurrencyController getInstance() { - if (instance == null) { - instance = new CurrencyController(); - } - return instance; - } - - /** - * Check if Vault economy is being used - */ - public boolean isUsingVault() { - return useVaultIfAvailable && VaultHelper.Enabled; - } - - /** - * Get player's balance (Vault or item count) - */ - public double getBalance(EntityPlayer player) { - if (isUsingVault()) { - return VaultHelper.getBalance(player); - } - // Fall back to counting items - if (fallbackCurrency != null) { - return NoppesUtilPlayer.countItems(player, fallbackCurrency, false, false); - } - return 0.0; - } - - /** - * Check if player can afford amount - */ - public boolean canAfford(EntityPlayer player, double amount) { - if (isUsingVault()) { - return VaultHelper.has(player, amount); - } - return getBalance(player) >= amount; - } - - /** - * Withdraw currency from player - */ - public boolean withdraw(EntityPlayer player, double amount) { - if (isUsingVault()) { - return VaultHelper.withdraw(player, amount); - } - // Remove items - if (fallbackCurrency != null) { - ItemStack toRemove = fallbackCurrency.copy(); - toRemove.stackSize = (int) Math.ceil(amount / itemToVaultRatio); - return NoppesUtilPlayer.consumeItem(player, toRemove, false, false); - } - return false; - } - - /** - * Deposit currency to player - */ - public boolean deposit(EntityPlayer player, double amount) { - if (isUsingVault()) { - return VaultHelper.deposit(player, amount); - } - // Give items - if (fallbackCurrency != null) { - ItemStack toGive = fallbackCurrency.copy(); - toGive.stackSize = (int) Math.floor(amount / itemToVaultRatio); - return player.inventory.addItemStackToInventory(toGive); - } - return false; - } - - /** - * Format currency for display - */ - public String format(double amount) { - if (isUsingVault()) { - return VaultHelper.formatCurrency(amount); - } - return String.format("%.0f", amount); - } - - // NBT Persistence - public NBTTagCompound writeToNBT(NBTTagCompound compound) { - compound.setBoolean("UseVault", useVaultIfAvailable); - compound.setDouble("ItemRatio", itemToVaultRatio); - if (fallbackCurrency != null) { - compound.setTag("FallbackItem", fallbackCurrency.writeToNBT(new NBTTagCompound())); - } - return compound; - } - - public void readFromNBT(NBTTagCompound compound) { - useVaultIfAvailable = compound.getBoolean("UseVault"); - itemToVaultRatio = compound.getDouble("ItemRatio"); - if (compound.hasKey("FallbackItem")) { - fallbackCurrency = ItemStack.loadItemStackFromNBT( - compound.getCompoundTag("FallbackItem")); - } - } -} -``` - -### 1.4 Player Currency Data - -**File: `src/main/java/noppes/npcs/controllers/data/PlayerCurrencyData.java`** - -```java -package noppes.npcs.controllers.data; - -import net.minecraft.nbt.NBTTagCompound; -import java.util.HashMap; - -public class PlayerCurrencyData { - private final PlayerData parent; - - // Transaction history (for auditing/display) - public HashMap transactionHistory = new HashMap<>(); - public double totalEarned = 0.0; - public double totalSpent = 0.0; - - public PlayerCurrencyData(PlayerData parent) { - this.parent = parent; - } - - public void recordTransaction(double amount, boolean isEarning) { - long timestamp = System.currentTimeMillis(); - transactionHistory.put(timestamp, isEarning ? amount : -amount); - if (isEarning) { - totalEarned += amount; - } else { - totalSpent += amount; - } - // Limit history size - if (transactionHistory.size() > 100) { - // Remove oldest entries - Long oldest = transactionHistory.keySet().stream() - .min(Long::compare).orElse(null); - if (oldest != null) { - transactionHistory.remove(oldest); - } - } - } - - public void loadNBTData(NBTTagCompound compound) { - if (compound.hasKey("CurrencyData")) { - NBTTagCompound data = compound.getCompoundTag("CurrencyData"); - totalEarned = data.getDouble("TotalEarned"); - totalSpent = data.getDouble("TotalSpent"); - } - } - - public void saveNBTData(NBTTagCompound compound) { - NBTTagCompound data = new NBTTagCompound(); - data.setDouble("TotalEarned", totalEarned); - data.setDouble("TotalSpent", totalSpent); - compound.setTag("CurrencyData", data); - } -} -``` - -### 1.5 Configuration UI - -Add currency configuration to the global settings GUI: - -**File: `src/main/java/noppes/npcs/client/gui/global/GuiNPCManageCurrency.java`** - -This GUI will allow server operators to: -- Enable/disable Vault integration -- Set fallback currency item -- Configure item-to-Vault ratio -- View server economy statistics - ---- - -## Feature 2: Trader Enhancements - -### 2.1 Overview - -Enhance the existing `RoleTrader` with: -- **Stock System**: Limited inventory that depletes when players buy -- **Per-Player vs Per-Server Stock**: Configure whether stock is shared or individual -- **Stock Reset Timer**: Automatic restocking based on configurable cooldown (like quest cooldowns) -- **Currency Integration**: Use Vault currency alongside or instead of item-based trading - -### 2.2 Trader Stock Configuration - -#### 2.2.1 Stock Data Class - -**File: `src/main/java/noppes/npcs/controllers/data/TraderStock.java`** - -```java -package noppes.npcs.controllers.data; - -import net.minecraft.nbt.NBTTagCompound; -import net.minecraft.nbt.NBTTagList; -import java.util.HashMap; - -public class TraderStock { - // Stock configuration - public boolean enableStock = false; - public boolean perPlayer = false; // false = per-server, true = per-player - - // Reset configuration (mirrors Quest cooldown system) - public EnumStockReset resetType = EnumStockReset.NONE; - public long customResetTime = 0; // For MCCUSTOM/RLCUSTOM types - - // Stock quantities per slot (max stock) - public int[] maxStock = new int[18]; - - // Current stock (per-server mode) - public int[] currentStock = new int[18]; - public long lastResetTime = 0; - - // Per-player stock tracking - public HashMap playerStock = new HashMap<>(); - - public TraderStock() { - for (int i = 0; i < 18; i++) { - maxStock[i] = -1; // -1 = unlimited - currentStock[i] = -1; - } - } - - /** - * Get available stock for a slot - */ - public int getAvailableStock(int slot, String playerName) { - if (!enableStock || maxStock[slot] < 0) { - return Integer.MAX_VALUE; // Unlimited - } - - if (perPlayer) { - PlayerTraderStock pStock = playerStock.get(playerName); - if (pStock == null) { - return maxStock[slot]; // Not purchased yet - } - return pStock.getStock(slot, maxStock[slot]); - } - - return currentStock[slot]; - } - - /** - * Consume stock when purchase is made - */ - public boolean consumeStock(int slot, String playerName, int amount) { - if (!enableStock || maxStock[slot] < 0) { - return true; // Unlimited - } - - if (perPlayer) { - PlayerTraderStock pStock = playerStock.computeIfAbsent( - playerName, k -> new PlayerTraderStock()); - return pStock.consumeStock(slot, maxStock[slot], amount); - } - - if (currentStock[slot] >= amount) { - currentStock[slot] -= amount; - return true; - } - return false; - } - - /** - * Check if stock should reset - */ - public boolean shouldReset(long currentTime) { - if (resetType == EnumStockReset.NONE) { - return false; - } - - long elapsed = currentTime - lastResetTime; - long resetInterval = getResetInterval(); - - return elapsed >= resetInterval; - } - - /** - * Reset all stock to max values - */ - public void resetStock(long currentTime) { - lastResetTime = currentTime; - - for (int i = 0; i < 18; i++) { - currentStock[i] = maxStock[i]; - } - - // Clear per-player stock - playerStock.clear(); - } - - /** - * Get reset interval in milliseconds - */ - public long getResetInterval() { - switch (resetType) { - case MCDAILY: - return 24000 * 50; // 24000 ticks * 50ms - case MCWEEKLY: - return 168000 * 50; - case MCCUSTOM: - return customResetTime * 50; - case RLDAILY: - return 86400000L; // 24 hours in ms - case RLWEEKLY: - return 604800000L; // 7 days in ms - case RLCUSTOM: - return customResetTime; - default: - return Long.MAX_VALUE; - } - } - - /** - * Get time until next reset (for display) - */ - public long getTimeUntilReset(long currentTime) { - if (resetType == EnumStockReset.NONE) { - return -1; - } - long elapsed = currentTime - lastResetTime; - long interval = getResetInterval(); - return Math.max(0, interval - elapsed); - } - - // NBT Serialization - public NBTTagCompound writeToNBT(NBTTagCompound compound) { - compound.setBoolean("EnableStock", enableStock); - compound.setBoolean("PerPlayer", perPlayer); - compound.setInteger("ResetType", resetType.ordinal()); - compound.setLong("CustomResetTime", customResetTime); - compound.setIntArray("MaxStock", maxStock); - compound.setIntArray("CurrentStock", currentStock); - compound.setLong("LastResetTime", lastResetTime); - - // Save per-player stock - NBTTagList playerList = new NBTTagList(); - for (Map.Entry entry : playerStock.entrySet()) { - NBTTagCompound playerTag = new NBTTagCompound(); - playerTag.setString("Player", entry.getKey()); - entry.getValue().writeToNBT(playerTag); - playerList.appendTag(playerTag); - } - compound.setTag("PlayerStock", playerList); - - return compound; - } - - public void readFromNBT(NBTTagCompound compound) { - enableStock = compound.getBoolean("EnableStock"); - perPlayer = compound.getBoolean("PerPlayer"); - resetType = EnumStockReset.values()[compound.getInteger("ResetType")]; - customResetTime = compound.getLong("CustomResetTime"); - maxStock = compound.getIntArray("MaxStock"); - currentStock = compound.getIntArray("CurrentStock"); - lastResetTime = compound.getLong("LastResetTime"); - - // Load per-player stock - NBTTagList playerList = compound.getTagList("PlayerStock", 10); - for (int i = 0; i < playerList.tagCount(); i++) { - NBTTagCompound playerTag = playerList.getCompoundTagAt(i); - String playerName = playerTag.getString("Player"); - PlayerTraderStock pStock = new PlayerTraderStock(); - pStock.readFromNBT(playerTag); - playerStock.put(playerName, pStock); - } - } -} - -// Inner class for per-player stock tracking -class PlayerTraderStock { - public int[] purchasedAmounts = new int[18]; - public long lastPurchaseTime = 0; - - public int getStock(int slot, int maxStock) { - return Math.max(0, maxStock - purchasedAmounts[slot]); - } - - public boolean consumeStock(int slot, int maxStock, int amount) { - if (getStock(slot, maxStock) >= amount) { - purchasedAmounts[slot] += amount; - lastPurchaseTime = System.currentTimeMillis(); - return true; - } - return false; - } - - public void writeToNBT(NBTTagCompound compound) { - compound.setIntArray("Purchased", purchasedAmounts); - compound.setLong("LastPurchase", lastPurchaseTime); - } - - public void readFromNBT(NBTTagCompound compound) { - purchasedAmounts = compound.getIntArray("Purchased"); - lastPurchaseTime = compound.getLong("LastPurchase"); - } -} -``` - -#### 2.2.2 Stock Reset Enum - -**File: `src/main/java/noppes/npcs/constants/EnumStockReset.java`** - -```java -package noppes.npcs.constants; - -public enum EnumStockReset { - NONE, // Never resets - MCDAILY, // Every 24000 MC ticks (1 MC day) - MCWEEKLY, // Every 168000 MC ticks (7 MC days) - MCCUSTOM, // Custom MC tick interval - RLDAILY, // Every 24 real hours - RLWEEKLY, // Every 7 real days - RLCUSTOM // Custom real-time interval -} -``` - -### 2.3 RoleTrader Modifications - -**File: `src/main/java/noppes/npcs/roles/RoleTrader.java`** - -Add the following to `RoleTrader`: - -```java -// Add new fields -public TraderStock stock = new TraderStock(); -public boolean useCurrency = false; // Use Vault/Currency system -public double[] slotPrices = new double[18]; // Currency prices per slot - -// Modify interact() to check stock reset -@Override -public void interact(EntityPlayer player) { - // Check for stock reset before opening GUI - if (stock.enableStock) { - long currentTime = stock.resetType.name().startsWith("RL") - ? System.currentTimeMillis() - : player.worldObj.getTotalWorldTime(); - - if (stock.shouldReset(currentTime)) { - stock.resetStock(currentTime); - // Save to market if using shared market - if (!marketName.isEmpty()) { - Market.save(this, marketName); - } - } - } - - npc.say(player, npc.advanced.getInteractLine()); - NoppesUtilServer.sendOpenGui(player, EnumGuiType.PlayerTrader, npc); -} - -// Modify writeToNBT -@Override -public NBTTagCompound writeToNBT(NBTTagCompound nbttagcompound) { - // ... existing code ... - - // Add stock data - nbttagcompound.setTag("Stock", stock.writeToNBT(new NBTTagCompound())); - nbttagcompound.setBoolean("UseCurrency", useCurrency); - - // Save currency prices - NBTTagList priceList = new NBTTagList(); - for (double price : slotPrices) { - NBTTagCompound priceTag = new NBTTagCompound(); - priceTag.setDouble("Price", price); - priceList.appendTag(priceTag); - } - nbttagcompound.setTag("SlotPrices", priceList); - - return nbttagcompound; -} - -// Modify readFromNBT -@Override -public void readFromNBT(NBTTagCompound nbttagcompound) { - // ... existing code ... - - // Load stock data - if (nbttagcompound.hasKey("Stock")) { - stock.readFromNBT(nbttagcompound.getCompoundTag("Stock")); - } - useCurrency = nbttagcompound.getBoolean("UseCurrency"); - - // Load currency prices - if (nbttagcompound.hasKey("SlotPrices")) { - NBTTagList priceList = nbttagcompound.getTagList("SlotPrices", 10); - for (int i = 0; i < priceList.tagCount() && i < 18; i++) { - slotPrices[i] = priceList.getCompoundTagAt(i).getDouble("Price"); - } - } -} -``` - -### 2.4 ContainerNPCTrader Modifications - -**File: `src/main/java/noppes/npcs/containers/ContainerNPCTrader.java`** - -Modify `slotClick()` to handle stock and currency: - -```java -public ItemStack slotClick(int slotIndex, EntityPlayer player) { - RoleTrader role = (RoleTrader) npc.roleInterface; - String playerName = player.getCommandSenderName(); - - // ... existing validation ... - - // Check stock availability - if (role.stock.enableStock) { - int available = role.stock.getAvailableStock(slotIndex, playerName); - if (available <= 0) { - // Out of stock - send message to player - return null; - } - } - - // Handle currency-based purchase - if (role.useCurrency && role.slotPrices[slotIndex] > 0) { - double price = role.slotPrices[slotIndex]; - CurrencyController currency = CurrencyController.getInstance(); - - if (!currency.canAfford(player, price)) { - // Can't afford - send message - return null; - } - - // Withdraw currency - if (!currency.withdraw(player, price)) { - return null; - } - } else { - // ... existing item-based currency logic ... - } - - // Consume stock - if (role.stock.enableStock) { - role.stock.consumeStock(slotIndex, playerName, 1); - } - - // ... existing item delivery logic ... - - // Save if using market - if (!role.marketName.isEmpty()) { - Market.save(role, role.marketName); - } - - return soldItem; -} -``` - -### 2.5 Trader Setup GUI Additions - -**File: `src/main/java/noppes/npcs/client/gui/roles/GuiNpcTraderSetup.java`** - -Add new configuration options: - -```java -// Add buttons/fields for: -// - "Enable Stock" toggle -// - "Per Player" / "Per Server" radio buttons -// - "Reset Type" dropdown (None, MC Daily, MC Weekly, etc.) -// - "Custom Reset Time" field -// - "Use Currency" toggle -// - Per-slot stock quantity inputs -// - Per-slot currency price inputs - -private void initStockGui() { - addButton(new GuiNpcButton(20, x, y, 80, 20, - "Enable Stock: " + (role.stock.enableStock ? "Yes" : "No"))); - - addButton(new GuiNpcButton(21, x, y + 22, 80, 20, - role.stock.perPlayer ? "Per Player" : "Per Server")); - - String[] resetTypes = {"None", "MC Daily", "MC Weekly", "MC Custom", - "RL Daily", "RL Weekly", "RL Custom"}; - addButton(new GuiNpcButton(22, x, y + 44, 80, 20, resetTypes, - role.stock.resetType.ordinal())); - - addButton(new GuiNpcButton(23, x, y + 66, 80, 20, - "Use Currency: " + (role.useCurrency ? "Yes" : "No"))); -} -``` - ---- - -## Feature 3: Auction House - -### 3.1 Overview - -The Auction House is a global trading system where players can: -- **List items for sale** at fixed prices or as auctions with bidding -- **Browse listings** by category, search, and filter -- **Bid on auctions** with automatic outbid notifications -- **Buy instantly** for buy-it-now prices -- **Manage their listings** (cancel, modify) -- **Collect proceeds and expired items** via mailbox or direct claim - -### 3.2 Auction Data Structures - -#### 3.2.1 AuctionListing - -**File: `src/main/java/noppes/npcs/controllers/data/AuctionListing.java`** - -```java -package noppes.npcs.controllers.data; - -import net.minecraft.item.ItemStack; -import net.minecraft.nbt.NBTTagCompound; -import java.util.UUID; - -public class AuctionListing { - public int id; - public UUID sellerUUID; - public String sellerName; - public ItemStack item; - - // Pricing - public double startingPrice; - public double buyoutPrice; // 0 = no buyout - public double currentBid; - public UUID highBidderUUID; - public String highBidderName; - - // Timing - public long listingTime; - public long expirationTime; - public EnumAuctionDuration duration; - - // Status - public EnumAuctionStatus status = EnumAuctionStatus.ACTIVE; - - // Category - public EnumAuctionCategory category = EnumAuctionCategory.MISC; - - public AuctionListing() {} - - public AuctionListing(UUID seller, String sellerName, ItemStack item, - double startPrice, double buyout, EnumAuctionDuration duration) { - this.sellerUUID = seller; - this.sellerName = sellerName; - this.item = item.copy(); - this.startingPrice = startPrice; - this.buyoutPrice = buyout; - this.currentBid = startPrice; - this.duration = duration; - this.listingTime = System.currentTimeMillis(); - this.expirationTime = listingTime + duration.getMillis(); - this.category = categorizeItem(item); - } - - /** - * Check if auction has expired - */ - public boolean isExpired() { - return System.currentTimeMillis() >= expirationTime; - } - - /** - * Get time remaining in milliseconds - */ - public long getTimeRemaining() { - return Math.max(0, expirationTime - System.currentTimeMillis()); - } - - /** - * Place a bid - */ - public boolean placeBid(UUID bidder, String bidderName, double amount) { - if (status != EnumAuctionStatus.ACTIVE) return false; - if (amount <= currentBid) return false; - if (bidder.equals(sellerUUID)) return false; // Can't bid on own item - - // Minimum bid increment (5% or 1, whichever is greater) - double minIncrement = Math.max(1, currentBid * 0.05); - if (amount < currentBid + minIncrement) return false; - - highBidderUUID = bidder; - highBidderName = bidderName; - currentBid = amount; - - return true; - } - - /** - * Execute buyout - */ - public boolean buyout(UUID buyer, String buyerName) { - if (status != EnumAuctionStatus.ACTIVE) return false; - if (buyoutPrice <= 0) return false; // No buyout available - if (buyer.equals(sellerUUID)) return false; - - highBidderUUID = buyer; - highBidderName = buyerName; - currentBid = buyoutPrice; - status = EnumAuctionStatus.SOLD; - - return true; - } - - /** - * Cancel listing (seller only, before any bids) - */ - public boolean cancel(UUID requester) { - if (!requester.equals(sellerUUID)) return false; - if (highBidderUUID != null) return false; // Has bids, can't cancel - - status = EnumAuctionStatus.CANCELLED; - return true; - } - - /** - * Auto-categorize item based on type - */ - private EnumAuctionCategory categorizeItem(ItemStack item) { - // Implement item categorization logic - // Check for weapons, armor, tools, food, blocks, etc. - return EnumAuctionCategory.MISC; - } - - // NBT Serialization - public NBTTagCompound writeToNBT(NBTTagCompound compound) { - compound.setInteger("Id", id); - compound.setString("SellerUUID", sellerUUID.toString()); - compound.setString("SellerName", sellerName); - compound.setTag("Item", item.writeToNBT(new NBTTagCompound())); - compound.setDouble("StartPrice", startingPrice); - compound.setDouble("BuyoutPrice", buyoutPrice); - compound.setDouble("CurrentBid", currentBid); - if (highBidderUUID != null) { - compound.setString("BidderUUID", highBidderUUID.toString()); - compound.setString("BidderName", highBidderName); - } - compound.setLong("ListingTime", listingTime); - compound.setLong("ExpirationTime", expirationTime); - compound.setInteger("Duration", duration.ordinal()); - compound.setInteger("Status", status.ordinal()); - compound.setInteger("Category", category.ordinal()); - return compound; - } - - public void readFromNBT(NBTTagCompound compound) { - id = compound.getInteger("Id"); - sellerUUID = UUID.fromString(compound.getString("SellerUUID")); - sellerName = compound.getString("SellerName"); - item = ItemStack.loadItemStackFromNBT(compound.getCompoundTag("Item")); - startingPrice = compound.getDouble("StartPrice"); - buyoutPrice = compound.getDouble("BuyoutPrice"); - currentBid = compound.getDouble("CurrentBid"); - if (compound.hasKey("BidderUUID")) { - highBidderUUID = UUID.fromString(compound.getString("BidderUUID")); - highBidderName = compound.getString("BidderName"); - } - listingTime = compound.getLong("ListingTime"); - expirationTime = compound.getLong("ExpirationTime"); - duration = EnumAuctionDuration.values()[compound.getInteger("Duration")]; - status = EnumAuctionStatus.values()[compound.getInteger("Status")]; - category = EnumAuctionCategory.values()[compound.getInteger("Category")]; - } -} -``` - -#### 3.2.2 Supporting Enums - -**File: `src/main/java/noppes/npcs/constants/EnumAuctionDuration.java`** - -```java -package noppes.npcs.constants; - -public enum EnumAuctionDuration { - SHORT(2 * 60 * 60 * 1000L), // 2 hours - MEDIUM(12 * 60 * 60 * 1000L), // 12 hours - LONG(24 * 60 * 60 * 1000L), // 24 hours - VERY_LONG(48 * 60 * 60 * 1000L); // 48 hours - - private final long millis; - - EnumAuctionDuration(long millis) { - this.millis = millis; - } - - public long getMillis() { - return millis; - } - - public String getDisplayName() { - switch (this) { - case SHORT: return "2 Hours"; - case MEDIUM: return "12 Hours"; - case LONG: return "24 Hours"; - case VERY_LONG: return "48 Hours"; - default: return "Unknown"; - } - } -} -``` - -**File: `src/main/java/noppes/npcs/constants/EnumAuctionStatus.java`** - -```java -package noppes.npcs.constants; - -public enum EnumAuctionStatus { - ACTIVE, // Currently listed - SOLD, // Successfully sold (buyout or auction end with bids) - EXPIRED, // Ended without bids - CANCELLED, // Cancelled by seller - CLAIMED // Item/money has been claimed -} -``` - -**File: `src/main/java/noppes/npcs/constants/EnumAuctionCategory.java`** - -```java -package noppes.npcs.constants; - -public enum EnumAuctionCategory { - WEAPONS, - ARMOR, - TOOLS, - FOOD, - POTIONS, - BLOCKS, - MATERIALS, - ENCHANTED, - MISC -} -``` - -### 3.3 Auction Controller - -**File: `src/main/java/noppes/npcs/controllers/AuctionController.java`** - -```java -package noppes.npcs.controllers; - -import net.minecraft.entity.player.EntityPlayer; -import net.minecraft.item.ItemStack; -import net.minecraft.nbt.CompressedStreamTools; -import net.minecraft.nbt.NBTTagCompound; -import net.minecraft.nbt.NBTTagList; -import noppes.npcs.CustomNpcs; -import noppes.npcs.LogWriter; -import noppes.npcs.controllers.data.AuctionListing; -import noppes.npcs.constants.*; - -import java.io.File; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; - -public class AuctionController { - private static AuctionController instance; - - // All listings (thread-safe for async operations) - public ConcurrentHashMap listings = new ConcurrentHashMap<>(); - - // Index by seller for quick lookup - private Map> sellerIndex = new ConcurrentHashMap<>(); - - // Pending claims (seller -> items/money to claim) - public Map> pendingClaims = new ConcurrentHashMap<>(); - - private int nextId = 1; - private long lastSaveTime = 0; - private static final long SAVE_INTERVAL = 60000; // Save every minute - - // Configuration - public double listingFee = 0.0; // Fee to list item - public double saleTax = 0.05; // 5% tax on sales - public int maxListingsPerPlayer = 20; - public boolean requireAuctioneer = true; // Require Auctioneer NPC to access - - public static AuctionController getInstance() { - if (instance == null) { - instance = new AuctionController(); - instance.load(); - } - return instance; - } - - /** - * Create a new auction listing - */ - public AuctionListing createListing(EntityPlayer seller, ItemStack item, - double startPrice, double buyout, - EnumAuctionDuration duration) { - UUID sellerUUID = seller.getUniqueID(); - - // Check max listings - Set sellerListings = sellerIndex.get(sellerUUID); - if (sellerListings != null && sellerListings.size() >= maxListingsPerPlayer) { - return null; - } - - // Charge listing fee - if (listingFee > 0) { - CurrencyController currency = CurrencyController.getInstance(); - if (!currency.withdraw(seller, listingFee)) { - return null; - } - } - - // Create listing - AuctionListing listing = new AuctionListing( - sellerUUID, seller.getCommandSenderName(), - item, startPrice, buyout, duration - ); - listing.id = nextId++; - - // Add to indexes - listings.put(listing.id, listing); - sellerIndex.computeIfAbsent(sellerUUID, k -> new HashSet<>()).add(listing.id); - - // Remove item from seller inventory - item.stackSize = 0; - - scheduleSave(); - return listing; - } - - /** - * Place a bid on an auction - */ - public boolean placeBid(EntityPlayer bidder, int listingId, double amount) { - AuctionListing listing = listings.get(listingId); - if (listing == null || listing.isExpired()) return false; - - CurrencyController currency = CurrencyController.getInstance(); - - // Check if bidder can afford - if (!currency.canAfford(bidder, amount)) return false; - - // Refund previous bidder - if (listing.highBidderUUID != null) { - addClaim(listing.highBidderUUID, listing.highBidderName, - null, listing.currentBid, "Outbid refund for: " + - listing.item.getDisplayName()); - } - - // Place bid - if (!listing.placeBid(bidder.getUniqueID(), - bidder.getCommandSenderName(), amount)) { - return false; - } - - // Hold currency from bidder - currency.withdraw(bidder, amount); - - scheduleSave(); - return true; - } - - /** - * Buyout an auction - */ - public boolean buyout(EntityPlayer buyer, int listingId) { - AuctionListing listing = listings.get(listingId); - if (listing == null || listing.buyoutPrice <= 0) return false; - - CurrencyController currency = CurrencyController.getInstance(); - - // Check if buyer can afford - if (!currency.canAfford(buyer, listing.buyoutPrice)) return false; - - // Refund previous bidder if any - if (listing.highBidderUUID != null) { - addClaim(listing.highBidderUUID, listing.highBidderName, - null, listing.currentBid, "Buyout refund for: " + - listing.item.getDisplayName()); - } - - // Execute buyout - if (!listing.buyout(buyer.getUniqueID(), buyer.getCommandSenderName())) { - return false; - } - - // Withdraw from buyer - currency.withdraw(buyer, listing.buyoutPrice); - - // Calculate seller proceeds (minus tax) - double proceeds = listing.buyoutPrice * (1 - saleTax); - - // Add claims for both parties - addClaim(listing.sellerUUID, listing.sellerName, null, proceeds, - "Sale of: " + listing.item.getDisplayName()); - addClaim(buyer.getUniqueID(), buyer.getCommandSenderName(), - listing.item, 0, "Purchased: " + listing.item.getDisplayName()); - - scheduleSave(); - return true; - } - - /** - * Cancel a listing (seller only) - */ - public boolean cancelListing(EntityPlayer player, int listingId) { - AuctionListing listing = listings.get(listingId); - if (listing == null) return false; - - if (!listing.cancel(player.getUniqueID())) return false; - - // Return item to seller - addClaim(listing.sellerUUID, listing.sellerName, listing.item, 0, - "Cancelled listing: " + listing.item.getDisplayName()); - - scheduleSave(); - return true; - } - - /** - * Process expired auctions (called periodically) - */ - public void processExpiredAuctions() { - long now = System.currentTimeMillis(); - - for (AuctionListing listing : listings.values()) { - if (listing.status != EnumAuctionStatus.ACTIVE) continue; - if (!listing.isExpired()) continue; - - if (listing.highBidderUUID != null) { - // Auction successful - has bids - listing.status = EnumAuctionStatus.SOLD; - - double proceeds = listing.currentBid * (1 - saleTax); - addClaim(listing.sellerUUID, listing.sellerName, null, proceeds, - "Auction sold: " + listing.item.getDisplayName()); - addClaim(listing.highBidderUUID, listing.highBidderName, - listing.item, 0, "Won auction: " + listing.item.getDisplayName()); - } else { - // No bids - return to seller - listing.status = EnumAuctionStatus.EXPIRED; - addClaim(listing.sellerUUID, listing.sellerName, listing.item, 0, - "Expired listing: " + listing.item.getDisplayName()); - } - } - - scheduleSave(); - } - - /** - * Get active listings with optional filters - */ - public List getActiveListings(EnumAuctionCategory category, - String search, - int page, int pageSize) { - return listings.values().stream() - .filter(l -> l.status == EnumAuctionStatus.ACTIVE) - .filter(l -> !l.isExpired()) - .filter(l -> category == null || l.category == category) - .filter(l -> search == null || search.isEmpty() || - l.item.getDisplayName().toLowerCase() - .contains(search.toLowerCase())) - .sorted(Comparator.comparingLong(l -> l.expirationTime)) - .skip((long) page * pageSize) - .limit(pageSize) - .collect(Collectors.toList()); - } - - /** - * Get player's own listings - */ - public List getPlayerListings(UUID playerUUID) { - Set ids = sellerIndex.get(playerUUID); - if (ids == null) return Collections.emptyList(); - - return ids.stream() - .map(listings::get) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - } - - /** - * Get pending claims for a player - */ - public List getPendingClaims(UUID playerUUID) { - return pendingClaims.getOrDefault(playerUUID, Collections.emptyList()); - } - - /** - * Claim pending items/money - */ - public boolean claim(EntityPlayer player, int claimIndex) { - List claims = pendingClaims.get(player.getUniqueID()); - if (claims == null || claimIndex >= claims.size()) return false; - - AuctionClaim claim = claims.get(claimIndex); - - // Give item or money - if (claim.item != null) { - if (!player.inventory.addItemStackToInventory(claim.item.copy())) { - return false; // Inventory full - } - } - if (claim.money > 0) { - CurrencyController.getInstance().deposit(player, claim.money); - } - - claims.remove(claimIndex); - scheduleSave(); - return true; - } - - private void addClaim(UUID playerUUID, String playerName, - ItemStack item, double money, String description) { - AuctionClaim claim = new AuctionClaim(item, money, description); - pendingClaims.computeIfAbsent(playerUUID, k -> new ArrayList<>()).add(claim); - } - - // Persistence - private void scheduleSave() { - long now = System.currentTimeMillis(); - if (now - lastSaveTime > SAVE_INTERVAL) { - save(); - lastSaveTime = now; - } - } - - public void save() { - try { - NBTTagCompound compound = new NBTTagCompound(); - compound.setInteger("NextId", nextId); - compound.setDouble("ListingFee", listingFee); - compound.setDouble("SaleTax", saleTax); - compound.setInteger("MaxListings", maxListingsPerPlayer); - - // Save listings - NBTTagList listingList = new NBTTagList(); - for (AuctionListing listing : listings.values()) { - listingList.appendTag(listing.writeToNBT(new NBTTagCompound())); - } - compound.setTag("Listings", listingList); - - // Save claims - NBTTagList claimList = new NBTTagList(); - for (Map.Entry> entry : pendingClaims.entrySet()) { - NBTTagCompound playerClaims = new NBTTagCompound(); - playerClaims.setString("UUID", entry.getKey().toString()); - NBTTagList claims = new NBTTagList(); - for (AuctionClaim claim : entry.getValue()) { - claims.appendTag(claim.writeToNBT(new NBTTagCompound())); - } - playerClaims.setTag("Claims", claims); - claimList.appendTag(playerClaims); - } - compound.setTag("PendingClaims", claimList); - - File file = new File(CustomNpcs.getWorldSaveDirectory(), "auction.dat"); - CompressedStreamTools.writeCompressed(compound, - new java.io.FileOutputStream(file)); - - } catch (Exception e) { - LogWriter.except(e); - } - } - - public void load() { - try { - File file = new File(CustomNpcs.getWorldSaveDirectory(), "auction.dat"); - if (!file.exists()) return; - - NBTTagCompound compound = CompressedStreamTools.readCompressed( - new java.io.FileInputStream(file)); - - nextId = compound.getInteger("NextId"); - listingFee = compound.getDouble("ListingFee"); - saleTax = compound.getDouble("SaleTax"); - maxListingsPerPlayer = compound.getInteger("MaxListings"); - - // Load listings - NBTTagList listingList = compound.getTagList("Listings", 10); - for (int i = 0; i < listingList.tagCount(); i++) { - AuctionListing listing = new AuctionListing(); - listing.readFromNBT(listingList.getCompoundTagAt(i)); - listings.put(listing.id, listing); - sellerIndex.computeIfAbsent(listing.sellerUUID, k -> new HashSet<>()) - .add(listing.id); - } - - // Load claims - NBTTagList claimList = compound.getTagList("PendingClaims", 10); - for (int i = 0; i < claimList.tagCount(); i++) { - NBTTagCompound playerClaims = claimList.getCompoundTagAt(i); - UUID playerUUID = UUID.fromString(playerClaims.getString("UUID")); - NBTTagList claims = playerClaims.getTagList("Claims", 10); - List claimsList = new ArrayList<>(); - for (int j = 0; j < claims.tagCount(); j++) { - AuctionClaim claim = new AuctionClaim(); - claim.readFromNBT(claims.getCompoundTagAt(j)); - claimsList.add(claim); - } - pendingClaims.put(playerUUID, claimsList); - } - - } catch (Exception e) { - LogWriter.except(e); - } - } -} - -// Claim data structure -class AuctionClaim { - public ItemStack item; - public double money; - public String description; - public long timestamp; - - public AuctionClaim() {} - - public AuctionClaim(ItemStack item, double money, String description) { - this.item = item != null ? item.copy() : null; - this.money = money; - this.description = description; - this.timestamp = System.currentTimeMillis(); - } - - public NBTTagCompound writeToNBT(NBTTagCompound compound) { - if (item != null) { - compound.setTag("Item", item.writeToNBT(new NBTTagCompound())); - } - compound.setDouble("Money", money); - compound.setString("Description", description); - compound.setLong("Timestamp", timestamp); - return compound; - } - - public void readFromNBT(NBTTagCompound compound) { - if (compound.hasKey("Item")) { - item = ItemStack.loadItemStackFromNBT(compound.getCompoundTag("Item")); - } - money = compound.getDouble("Money"); - description = compound.getString("Description"); - timestamp = compound.getLong("Timestamp"); - } -} -``` - -### 3.4 Auctioneer Role - -**File: `src/main/java/noppes/npcs/roles/RoleAuctioneer.java`** - -```java -package noppes.npcs.roles; - -import net.minecraft.entity.player.EntityPlayer; -import net.minecraft.nbt.NBTTagCompound; -import noppes.npcs.NoppesUtilServer; -import noppes.npcs.constants.EnumGuiType; -import noppes.npcs.entity.EntityNPCInterface; - -public class RoleAuctioneer extends RoleInterface { - - // Configuration - public String auctionHouseName = "Global"; // For future multi-auction support - public boolean allowSelling = true; - public boolean allowBuying = true; - public boolean showExpiredListings = true; - - // Custom messages - public String welcomeMessage = ""; - public String sellDisabledMessage = "This auctioneer does not accept listings."; - public String buyDisabledMessage = "This auctioneer does not sell items."; - - public RoleAuctioneer(EntityNPCInterface npc) { - super(npc); - } - - @Override - public NBTTagCompound writeToNBT(NBTTagCompound compound) { - compound.setString("AuctionHouse", auctionHouseName); - compound.setBoolean("AllowSelling", allowSelling); - compound.setBoolean("AllowBuying", allowBuying); - compound.setBoolean("ShowExpired", showExpiredListings); - compound.setString("WelcomeMsg", welcomeMessage); - compound.setString("SellDisabledMsg", sellDisabledMessage); - compound.setString("BuyDisabledMsg", buyDisabledMessage); - return compound; - } - - @Override - public void readFromNBT(NBTTagCompound compound) { - auctionHouseName = compound.getString("AuctionHouse"); - allowSelling = compound.getBoolean("AllowSelling"); - allowBuying = compound.getBoolean("AllowBuying"); - showExpiredListings = compound.getBoolean("ShowExpired"); - welcomeMessage = compound.getString("WelcomeMsg"); - sellDisabledMessage = compound.getString("SellDisabledMsg"); - buyDisabledMessage = compound.getString("BuyDisabledMsg"); - } - - @Override - public void interact(EntityPlayer player) { - // Show welcome message if set - if (!welcomeMessage.isEmpty()) { - npc.say(player, npc.advanced.getInteractLine()); - } - - // Open auction house GUI - NoppesUtilServer.sendOpenGui(player, EnumGuiType.PlayerAuction, npc); - } -} -``` - -### 3.5 Update EnumRoleType - -**File: `src/main/java/noppes/npcs/constants/EnumRoleType.java`** - -```java -public enum EnumRoleType { - None, - Trader, - Follower, - Bank, - Transporter, - Postman, - Companion, - Mount, - Auctioneer // Add this -} -``` - -### 3.6 Update DataAdvanced - -**File: `src/main/java/noppes/npcs/DataAdvanced.java`** - -Add to `setRole()` method: - -```java -else if (role == EnumRoleType.Auctioneer && !(npc.roleInterface instanceof RoleAuctioneer)) - npc.roleInterface = new RoleAuctioneer(npc); -``` - -### 3.7 Update EnumGuiType - -**File: `src/main/java/noppes/npcs/constants/EnumGuiType.java`** - -Add new GUI types: - -```java -// Auction House GUIs -PlayerAuction, // Main auction browse/buy interface -PlayerAuctionSell, // Create new listing -PlayerAuctionMyListings, // View own listings -PlayerAuctionClaims, // Claim items/money -AuctioneerSetup, // NPC configuration -ManageAuctions // Admin management -``` - -### 3.8 Auction GUI System - -The auction GUI system will consist of multiple interconnected screens: - -#### 3.8.1 Main Auction Browser - -**File: `src/main/java/noppes/npcs/client/gui/player/GuiAuctionBrowse.java`** - -Features: -- Category filter tabs (Weapons, Armor, Tools, etc.) -- Search bar for item names -- Sort options (Time remaining, Price, Recently listed) -- Paginated listing display -- Quick buy/bid buttons - -#### 3.8.2 Create Listing GUI - -**File: `src/main/java/noppes/npcs/client/gui/player/GuiAuctionSell.java`** - -Features: -- Item slot for the item to sell -- Starting price input -- Buyout price input (optional) -- Duration selector -- Listing fee display -- Confirm button - -#### 3.8.3 My Listings GUI - -**File: `src/main/java/noppes/npcs/client/gui/player/GuiAuctionMyListings.java`** - -Features: -- List of player's active listings -- Current bid status -- Time remaining -- Cancel button (if no bids) - -#### 3.8.4 Claims GUI - -**File: `src/main/java/noppes/npcs/client/gui/player/GuiAuctionClaims.java`** - -Features: -- List of pending claims (items won, money from sales, refunds) -- Claim button for each item -- Claim all button - -### 3.9 Network Packets - -#### 3.9.1 Auction Action Packet - -**File: `src/main/java/kamkeel/npcs/network/packets/player/AuctionActionPacket.java`** - -```java -package kamkeel.npcs.network.packets.player; - -public class AuctionActionPacket extends AbstractPacket { - - public enum Action { - CREATE_LISTING, - PLACE_BID, - BUYOUT, - CANCEL, - CLAIM, - CLAIM_ALL, - BROWSE, - MY_LISTINGS - } - - private Action action; - private int listingId; - private double amount; - private NBTTagCompound itemData; - private int duration; - private double buyoutPrice; - private int category; - private String search; - private int page; - - // Implement sendData/receiveData... -} -``` - -#### 3.9.2 Auction Sync Packet - -**File: `src/main/java/kamkeel/npcs/network/packets/data/AuctionSyncPacket.java`** - -Used to send auction listing data to clients for GUI display. - -### 3.10 Periodic Tasks - -Add to server tick handler to process expired auctions: - -**File: `src/main/java/noppes/npcs/ServerTickHandler.java`** - -```java -private int auctionCheckCounter = 0; - -@SubscribeEvent -public void onServerTick(TickEvent.ServerTickEvent event) { - // ... existing code ... - - // Check expired auctions every 5 minutes (6000 ticks) - if (++auctionCheckCounter >= 6000) { - auctionCheckCounter = 0; - AuctionController.getInstance().processExpiredAuctions(); - } -} -``` - ---- - -## Implementation Order - -The features should be implemented in this order due to dependencies: - -### Phase 1: Currency System (Foundation) -1. Add VaultAPI dependency to build files -2. Create `VaultHelper.java` with reflection-based integration -3. Create `CurrencyController.java` -4. Add initialization hook in `CustomNpcs.java` -5. Create `PlayerCurrencyData.java` and integrate with `PlayerData` -6. Create currency configuration GUI -7. Test with and without Vault present - -### Phase 2: Trader Enhancements -1. Create `EnumStockReset.java` -2. Create `TraderStock.java` -3. Modify `RoleTrader.java` to add stock and currency fields -4. Modify `ContainerNPCTrader.java` to handle stock/currency -5. Update `GuiNpcTraderSetup.java` with new options -6. Update network packets for stock synchronization -7. Test stock depletion and reset timers - -### Phase 3: Auction House -1. Create auction enums (`EnumAuctionDuration`, `EnumAuctionStatus`, `EnumAuctionCategory`) -2. Create `AuctionListing.java` and `AuctionClaim` classes -3. Create `AuctionController.java` -4. Update `EnumRoleType.java` to add Auctioneer -5. Create `RoleAuctioneer.java` -6. Update `DataAdvanced.java` for role instantiation -7. Update `EnumGuiType.java` with auction GUIs -8. Create auction containers -9. Create auction GUI classes -10. Create network packets -11. Add periodic task for expired auctions -12. Create auctioneer setup GUI -13. Comprehensive testing - ---- - -## File Summary - -### New Files to Create - -| File | Description | -|------|-------------| -| `kamkeel/npcs/compat/VaultHelper.java` | VaultAPI integration | -| `kamkeel/npcs/controllers/CurrencyController.java` | Currency management | -| `noppes/npcs/controllers/data/PlayerCurrencyData.java` | Per-player currency data | -| `noppes/npcs/controllers/data/TraderStock.java` | Trader stock configuration | -| `noppes/npcs/constants/EnumStockReset.java` | Stock reset types | -| `noppes/npcs/controllers/AuctionController.java` | Auction house management | -| `noppes/npcs/controllers/data/AuctionListing.java` | Auction listing data | -| `noppes/npcs/constants/EnumAuctionDuration.java` | Auction duration options | -| `noppes/npcs/constants/EnumAuctionStatus.java` | Auction status states | -| `noppes/npcs/constants/EnumAuctionCategory.java` | Item categories | -| `noppes/npcs/roles/RoleAuctioneer.java` | Auctioneer NPC role | -| `noppes/npcs/containers/ContainerAuction*.java` | Auction containers | -| `noppes/npcs/client/gui/player/GuiAuction*.java` | Auction GUIs | -| `noppes/npcs/client/gui/roles/GuiNpcAuctioneerSetup.java` | Auctioneer config | -| `kamkeel/npcs/network/packets/player/AuctionActionPacket.java` | Auction actions | -| `kamkeel/npcs/network/packets/data/AuctionSyncPacket.java` | Auction data sync | - -### Files to Modify - -| File | Changes | -|------|---------| -| `dependencies.gradle` | Add VaultAPI dependency | -| `repositories.gradle` | Add Vault repository | -| `CustomNpcs.java` | Initialize VaultHelper, AuctionController | -| `EnumRoleType.java` | Add Auctioneer | -| `EnumGuiType.java` | Add auction GUI types | -| `DataAdvanced.java` | Handle Auctioneer role | -| `RoleTrader.java` | Add stock and currency support | -| `ContainerNPCTrader.java` | Handle stock/currency transactions | -| `GuiNpcTraderSetup.java` | Add stock/currency UI | -| `PlayerData.java` | Add PlayerCurrencyData | -| `CommonProxy.java` | Register auction containers | -| `ServerTickHandler.java` | Add auction expiration check | -| `PacketHandler.java` | Register auction packets | - ---- - -## Testing Strategy - -### Unit Testing -- Currency operations (deposit, withdraw, balance) -- Stock depletion and reset calculations -- Auction bid validation -- Auction expiration handling - -### Integration Testing -- VaultAPI integration on hybrid servers -- Fallback to item currency when Vault unavailable -- Stock persistence across server restarts -- Auction persistence and recovery - -### User Acceptance Testing -- Complete trading flow with stock limits -- Complete auction flow (list → bid → win → claim) -- Currency display formatting -- GUI usability - -### Edge Cases -- Negative currency amounts -- Concurrent bids on same auction -- Server crash during transaction -- Player offline when auction ends -- Maximum listings per player -- Inventory full when claiming - ---- - -## API/Scripting Extensions - -Consider adding these scripting hooks: - -```java -// ICurrency interface for scripts -double getBalance(IPlayer player); -boolean withdraw(IPlayer player, double amount); -boolean deposit(IPlayer player, double amount); -String format(double amount); - -// IAuction interface for scripts -IAuctionListing createListing(IPlayer seller, IItemStack item, - double startPrice, double buyout, int durationHours); -boolean cancelListing(int listingId); -List getPlayerListings(IPlayer player); - -// Events -onAuctionCreated(IAuctionListing listing); -onAuctionBid(IAuctionListing listing, IPlayer bidder, double amount); -onAuctionSold(IAuctionListing listing, IPlayer buyer, double price); -onAuctionExpired(IAuctionListing listing); -onTraderPurchase(ITrader trader, IPlayer buyer, int slot, int stockRemaining); -onTraderStockReset(ITrader trader); -``` - ---- - -## Conclusion - -This implementation plan provides a comprehensive roadmap for the CustomNPC+ 1.11 Market Update. The three features are designed to work together seamlessly: - -1. **Currency System** provides the foundation for monetary transactions -2. **Trader Enhancements** modernize the existing trader with stock and currency support -3. **Auction House** creates a global marketplace leveraging the currency system - -The implementation follows existing codebase patterns and can be developed incrementally, with each phase building on the previous one. diff --git a/src/api b/src/api index 866be1fef..339e0f164 160000 --- a/src/api +++ b/src/api @@ -1 +1 @@ -Subproject commit 866be1fef5bf37a002379140771096cebe29f286 +Subproject commit 339e0f164e8c7b08c3469f61027fa25aa9e5c118 diff --git a/src/main/java/foxz/utils/Market.java b/src/main/java/foxz/utils/Market.java index ca260b90b..54905423c 100644 --- a/src/main/java/foxz/utils/Market.java +++ b/src/main/java/foxz/utils/Market.java @@ -31,9 +31,6 @@ public class Market { NBTTagCompound roleCompound = r.writeNBT(new NBTTagCompound()); roleCompound.setString("SaveName", name); putMarketCache(name, roleCompound); - if (r.recordHistory) - return; - saveFile(roleCompound); } diff --git a/src/main/java/kamkeel/npcs/network/PacketHandler.java b/src/main/java/kamkeel/npcs/network/PacketHandler.java index 78c815c26..c87a5aa2d 100644 --- a/src/main/java/kamkeel/npcs/network/PacketHandler.java +++ b/src/main/java/kamkeel/npcs/network/PacketHandler.java @@ -57,6 +57,7 @@ import kamkeel.npcs.network.packets.player.CompanionActionPacket; import kamkeel.npcs.network.packets.player.DialogSelectPacket; 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.MailActionPacket; import kamkeel.npcs.network.packets.player.SaveBookPacket; @@ -579,6 +580,7 @@ public class PacketHandler { PLAYER_PACKET.registerPacket(new CompanionActionPacket()); PLAYER_PACKET.registerPacket(new GetNPCRole()); + PLAYER_PACKET.registerPacket(new GetTraderData()); PLAYER_PACKET.registerPacket(new InputDevicePacket()); diff --git a/src/main/java/kamkeel/npcs/network/enums/EnumPlayerPacket.java b/src/main/java/kamkeel/npcs/network/enums/EnumPlayerPacket.java index 0c1db3cd9..48f400fb2 100644 --- a/src/main/java/kamkeel/npcs/network/enums/EnumPlayerPacket.java +++ b/src/main/java/kamkeel/npcs/network/enums/EnumPlayerPacket.java @@ -13,6 +13,7 @@ public enum EnumPlayerPacket { SaveBook, CompanionAction, GetRole, + GetTraderData, InputDevice, SpecialKeyState, ScreenSize, @@ -33,4 +34,7 @@ public enum EnumPlayerPacket { ProfileGet, ProfileGetInfo, SyncRevisionInfo, + + // Auction + AuctionAction, } diff --git a/src/main/java/kamkeel/npcs/network/packets/player/GetTraderData.java b/src/main/java/kamkeel/npcs/network/packets/player/GetTraderData.java new file mode 100644 index 000000000..5b7cb7dfe --- /dev/null +++ b/src/main/java/kamkeel/npcs/network/packets/player/GetTraderData.java @@ -0,0 +1,77 @@ +package kamkeel.npcs.network.packets.player; + +import io.netty.buffer.ByteBuf; +import kamkeel.npcs.network.AbstractPacket; +import kamkeel.npcs.network.PacketChannel; +import kamkeel.npcs.network.PacketHandler; +import kamkeel.npcs.network.enums.EnumPlayerPacket; +import kamkeel.npcs.network.packets.data.large.GuiDataPacket; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.nbt.NBTTagCompound; +import noppes.npcs.controllers.data.PlayerData; +import noppes.npcs.roles.RoleTrader; + +import java.io.IOException; + +/** + * Client requests trader-specific GUI data from server. + * Server responds with player balance, stock info, currency costs via GuiDataPacket. + */ +public class GetTraderData extends AbstractPacket { + public static final String packetName = "Player|GetTraderData"; + + @Override + public Enum getType() { + return EnumPlayerPacket.GetTraderData; + } + + @Override + public PacketChannel getChannel() { + return PacketHandler.PLAYER_PACKET; + } + + @Override + public boolean needsNPC() { + return true; + } + + @Override + public void sendData(ByteBuf out) throws IOException { + // No data needed for request + } + + @Override + public void receiveData(ByteBuf in, EntityPlayer player) throws IOException { + if (!(player instanceof EntityPlayerMP)) + return; + if (!(npc.roleInterface instanceof RoleTrader)) + return; + + RoleTrader role = (RoleTrader) npc.roleInterface; + NBTTagCompound compound = new NBTTagCompound(); + + // Player balance + PlayerData data = PlayerData.get(player); + compound.setLong("Balance", data.currencyData.getBalance()); + + // Stock info + compound.setBoolean("StockEnabled", role.stock.enableStock); + compound.setLong("ResetTimeMillis", role.getResetTimeRemainingMillis()); + + // Available stock per slot (player-specific if perPlayer mode) + String playerName = player.getCommandSenderName(); + int[] stock = new int[18]; + for (int i = 0; i < 18; i++) { + stock[i] = role.getAvailableStock(i, playerName); + } + compound.setIntArray("Stock", stock); + + // Currency cost per slot + for (int i = 0; i < 18; i++) { + compound.setLong("Cost" + i, role.getCurrencyCost(i)); + } + + GuiDataPacket.sendGuiData((EntityPlayerMP) player, compound); + } +} diff --git a/src/main/java/noppes/npcs/NoppesUtilServer.java b/src/main/java/noppes/npcs/NoppesUtilServer.java index 89bdf58c8..08c04f6f4 100644 --- a/src/main/java/noppes/npcs/NoppesUtilServer.java +++ b/src/main/java/noppes/npcs/NoppesUtilServer.java @@ -384,6 +384,7 @@ public class NoppesUtilServer { if (gui == EnumGuiType.PlayerFollower || gui == EnumGuiType.PlayerFollowerHire || gui == EnumGuiType.PlayerTrader || gui == EnumGuiType.PlayerTransporter) { sendRoleData(player, npc); } + // Note: Trader-specific data (balance, stock, costs) is requested by client via GetTraderData packet } private static ArrayList getScrollData(EntityPlayer player, EnumGuiType gui, EntityNPCInterface npc) { diff --git a/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilityConfig.java b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilityConfig.java index 6d12693bb..c88c18095 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilityConfig.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/SubGuiAbilityConfig.java @@ -144,20 +144,20 @@ public class SubGuiAbilityConfig extends SubGuiInterface implements ITextfieldLi // ═══════════════════════════════════════════════════════════════════════ // TOP TABS using GuiMenuTopButton // ═══════════════════════════════════════════════════════════════════════ - GuiMenuTopButton generalTab = new GuiMenuTopButton(90, guiLeft + 4, guiTop - 17, "ability.tab.general"); + GuiMenuTopButton generalTab = new GuiMenuTopButton(90, guiLeft + 4, guiTop - 17, "menu.general"); generalTab.active = (activeTab == TAB_GENERAL); addTopButton(generalTab); GuiMenuTopButton lastTab = generalTab; if (hasTypeSettings) { - GuiMenuTopButton typeTab = new GuiMenuTopButton(91, lastTab, "ability.tab.type"); + GuiMenuTopButton typeTab = new GuiMenuTopButton(91, lastTab, "gui.type"); typeTab.active = (activeTab == TAB_TYPE); addTopButton(typeTab); lastTab = typeTab; } - GuiMenuTopButton targetTab = new GuiMenuTopButton(92, lastTab, "ability.tab.target"); + GuiMenuTopButton targetTab = new GuiMenuTopButton(92, lastTab, "script.target"); targetTab.active = (activeTab == TAB_TARGET); addTopButton(targetTab); lastTab = targetTab; @@ -435,7 +435,7 @@ public class SubGuiAbilityConfig extends SubGuiInterface implements ITextfieldLi // ───────────────────────────────────────────────────────────────────── // SOUNDS SECTION // ───────────────────────────────────────────────────────────────────── - addLabel(new GuiNpcLabel(30, "ability.sounds", col1LabelX, y)); + addLabel(new GuiNpcLabel(30, "advanced.sounds", col1LabelX, y)); y += 14; // Wind Up Sound @@ -455,7 +455,7 @@ public class SubGuiAbilityConfig extends SubGuiInterface implements ITextfieldLi // ───────────────────────────────────────────────────────────────────── // ANIMATIONS SECTION // ───────────────────────────────────────────────────────────────────── - addLabel(new GuiNpcLabel(33, "ability.animations", col1LabelX, y)); + addLabel(new GuiNpcLabel(33, "menu.animations", col1LabelX, y)); y += 14; // Wind Up Animation @@ -486,7 +486,8 @@ public class SubGuiAbilityConfig extends SubGuiInterface implements ITextfieldLi // Telegraph Type (separate column) String typeKey = ability.getTelegraphType().name().toLowerCase(); addLabel(new GuiNpcLabel(42, "ability.telegraphType", guiLeft + 200, y + 5)); - addLabel(new GuiNpcLabel(43, "ability.telegraph." + typeKey, guiLeft + 280, y + 5)); + String telegraphLangKey = typeKey.equals("none") ? "gui.none" : "telegraph." + typeKey; + addLabel(new GuiNpcLabel(43, telegraphLangKey, guiLeft + 280, y + 5)); y += 24; diff --git a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityBeam.java b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityBeam.java index e951e5fbd..fb3e6b074 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityBeam.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityBeam.java @@ -28,7 +28,7 @@ public class SubGuiAbilityBeam extends SubGuiAbilityConfig { int col2FieldX = guiLeft + 205; // Row 1: Damage + Damage Interval - addLabel(new GuiNpcLabel(100, "ability.damage", labelX, y + 5)); + addLabel(new GuiNpcLabel(100, "enchantment.damage", labelX, y + 5)); addTextField(createFloatField(100, fieldX, y, 50, beam.getDamage())); addLabel(new GuiNpcLabel(101, "ability.dmgInterval", col2LabelX, y + 5)); diff --git a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityCharge.java b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityCharge.java index a7b19b05c..d3ff5fd81 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityCharge.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityCharge.java @@ -27,10 +27,10 @@ public class SubGuiAbilityCharge extends SubGuiAbilityConfig { int col2FieldX = guiLeft + 205; // Row 1: Damage + Speed - addLabel(new GuiNpcLabel(100, "ability.damage", labelX, y + 5)); + addLabel(new GuiNpcLabel(100, "enchantment.damage", labelX, y + 5)); addTextField(createFloatField(100, fieldX, y, 50, charge.getDamage())); - addLabel(new GuiNpcLabel(101, "ability.speed", col2LabelX, y + 5)); + addLabel(new GuiNpcLabel(101, "stats.speed", col2LabelX, y + 5)); addTextField(createFloatField(101, col2FieldX, y, 50, charge.getChargeSpeed())); y += 24; diff --git a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityCutter.java b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityCutter.java index b825789a8..d917282f2 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityCutter.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityCutter.java @@ -28,10 +28,10 @@ public class SubGuiAbilityCutter extends SubGuiAbilityConfig { int col2FieldX = guiLeft + 205; // Row 1: Damage + Range - addLabel(new GuiNpcLabel(100, "ability.damage", labelX, y + 5)); + addLabel(new GuiNpcLabel(100, "enchantment.damage", labelX, y + 5)); addTextField(createFloatField(100, fieldX, y, 50, cutter.getDamage())); - addLabel(new GuiNpcLabel(101, "ability.range", col2LabelX, y + 5)); + addLabel(new GuiNpcLabel(101, "gui.range", col2LabelX, y + 5)); addTextField(createFloatField(101, col2FieldX, y, 50, cutter.getRange())); y += 24; diff --git a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityDash.java b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityDash.java index 2d0d5c33a..7ae0ea1f5 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityDash.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityDash.java @@ -28,7 +28,7 @@ public class SubGuiAbilityDash extends SubGuiAbilityConfig { int col2FieldX = guiLeft + 205; // Row 1: Dash Mode - addLabel(new GuiNpcLabel(100, "ability.dashMode", labelX, y + 5)); + addLabel(new GuiNpcLabel(100, "ability.mode", labelX, y + 5)); String[] dashModes = {"Aggressive", "Defensive"}; addButton(new GuiNpcButton(100, fieldX, y, 80, 20, dashModes, dash.getDashMode().ordinal())); diff --git a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityHeal.java b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityHeal.java index 365460672..30ccb739e 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityHeal.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityHeal.java @@ -40,7 +40,7 @@ public class SubGuiAbilityHeal extends SubGuiAbilityConfig { addLabel(new GuiNpcLabel(102, "ability.healRadius", labelX, y + 5)); addTextField(createFloatField(102, fieldX, y, 50, heal.getHealRadius())); - addLabel(new GuiNpcLabel(103, "ability.instant", col2LabelX, y + 5)); + addLabel(new GuiNpcLabel(103, "dialog.instant", col2LabelX, y + 5)); addButton(new GuiNpcButton(103, col2FieldX, y, 50, 20, new String[]{"gui.no", "gui.yes"}, heal.isInstantHeal() ? 1 : 0)); y += 24; diff --git a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityHeavyHit.java b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityHeavyHit.java index c045833b9..5c0d99c48 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityHeavyHit.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityHeavyHit.java @@ -27,7 +27,7 @@ public class SubGuiAbilityHeavyHit extends SubGuiAbilityConfig { int col2FieldX = guiLeft + 205; // Row 1: Damage + Potion Time - addLabel(new GuiNpcLabel(100, "ability.damage", labelX, y + 5)); + addLabel(new GuiNpcLabel(100, "enchantment.damage", labelX, y + 5)); addTextField(createFloatField(100, fieldX, y, 50, heavyHit.getDamage())); addLabel(new GuiNpcLabel(101, "ability.potionTime", col2LabelX, y + 5)); diff --git a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityOrb.java b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityOrb.java index 5c82c42f8..fd75700c0 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityOrb.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityOrb.java @@ -28,16 +28,16 @@ public class SubGuiAbilityOrb extends SubGuiAbilityConfig { int col2FieldX = guiLeft + 205; // Row 1: Damage + Speed - addLabel(new GuiNpcLabel(100, "ability.damage", labelX, y + 5)); + addLabel(new GuiNpcLabel(100, "enchantment.damage", labelX, y + 5)); addTextField(createFloatField(100, fieldX, y, 50, orb.getDamage())); - addLabel(new GuiNpcLabel(101, "ability.speed", col2LabelX, y + 5)); + addLabel(new GuiNpcLabel(101, "stats.speed", col2LabelX, y + 5)); addTextField(createFloatField(101, col2FieldX, y, 50, orb.getOrbSpeed())); y += 24; // Row 2: Orb Size + Max Distance - addLabel(new GuiNpcLabel(102, "ability.orbSize", labelX, y + 5)); + addLabel(new GuiNpcLabel(102, "stats.size", labelX, y + 5)); addTextField(createFloatField(102, fieldX, y, 50, orb.getOrbSize())); addLabel(new GuiNpcLabel(103, "ability.maxDist", col2LabelX, y + 5)); diff --git a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityProjectile.java b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityProjectile.java index cbde41b7f..88d7bbf00 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityProjectile.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityProjectile.java @@ -28,10 +28,10 @@ public class SubGuiAbilityProjectile extends SubGuiAbilityConfig { int col2FieldX = guiLeft + 205; // Row 1: Damage + Speed - addLabel(new GuiNpcLabel(100, "ability.damage", labelX, y + 5)); + addLabel(new GuiNpcLabel(100, "enchantment.damage", labelX, y + 5)); addTextField(createFloatField(100, fieldX, y, 50, projectile.getDamage())); - addLabel(new GuiNpcLabel(101, "ability.speed", col2LabelX, y + 5)); + addLabel(new GuiNpcLabel(101, "stats.speed", col2LabelX, y + 5)); addTextField(createFloatField(101, col2FieldX, y, 50, projectile.getSpeed())); y += 24; diff --git a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityShockwave.java b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityShockwave.java index 9230e242c..38ab22158 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityShockwave.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityShockwave.java @@ -27,7 +27,7 @@ public class SubGuiAbilityShockwave extends SubGuiAbilityConfig { int col2FieldX = guiLeft + 205; // Row 1: Damage + Push Radius - addLabel(new GuiNpcLabel(100, "ability.damage", labelX, y + 5)); + addLabel(new GuiNpcLabel(100, "enchantment.damage", labelX, y + 5)); addTextField(createFloatField(100, fieldX, y, 50, shockwave.getDamage())); addLabel(new GuiNpcLabel(101, "ability.pushRadius", col2LabelX, y + 5)); diff --git a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilitySlam.java b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilitySlam.java index 2fdb0d7fb..7f8c7194e 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilitySlam.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilitySlam.java @@ -27,7 +27,7 @@ public class SubGuiAbilitySlam extends SubGuiAbilityConfig { int col2FieldX = guiLeft + 205; // Row 1: Damage + Radius - addLabel(new GuiNpcLabel(100, "ability.damage", labelX, y + 5)); + addLabel(new GuiNpcLabel(100, "enchantment.damage", labelX, y + 5)); addTextField(createFloatField(100, fieldX, y, 50, slam.getDamage())); addLabel(new GuiNpcLabel(101, "ability.radius", col2LabelX, y + 5)); diff --git a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityTeleport.java b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityTeleport.java index c4e9154e5..5570d944f 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityTeleport.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityTeleport.java @@ -71,7 +71,7 @@ public class SubGuiAbilityTeleport extends SubGuiAbilityConfig { } // Damage + Damage Radius - addLabel(new GuiNpcLabel(106, "ability.damage", labelX, y + 5)); + addLabel(new GuiNpcLabel(106, "enchantment.damage", labelX, y + 5)); addTextField(createFloatField(106, fieldX, y, 50, teleport.getDamage())); addLabel(new GuiNpcLabel(107, "ability.dmgRadius", col2LabelX, y + 5)); diff --git a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityTrap.java b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityTrap.java index 8c8d92bb4..88529fe68 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityTrap.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityTrap.java @@ -38,7 +38,7 @@ public class SubGuiAbilityTrap extends SubGuiAbilityConfig { y += 24; // Row 2: Damage + Damage Radius - addLabel(new GuiNpcLabel(102, "ability.damage", labelX, y + 5)); + addLabel(new GuiNpcLabel(102, "enchantment.damage", labelX, y + 5)); addTextField(createFloatField(102, fieldX, y, 50, trap.getDamage())); addLabel(new GuiNpcLabel(103, "ability.dmgRadius", col2LabelX, y + 5)); diff --git a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityVortex.java b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityVortex.java index 6978abce7..a1abbd661 100644 --- a/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityVortex.java +++ b/src/main/java/noppes/npcs/client/gui/advanced/ability/SubGuiAbilityVortex.java @@ -37,7 +37,7 @@ public class SubGuiAbilityVortex extends SubGuiAbilityConfig { y += 24; // Row 2: Damage + Knockback - addLabel(new GuiNpcLabel(102, "ability.damage", labelX, y + 5)); + addLabel(new GuiNpcLabel(102, "enchantment.damage", labelX, y + 5)); addTextField(createFloatField(102, fieldX, y, 50, vortex.getDamage())); addLabel(new GuiNpcLabel(103, "ability.knockback", col2LabelX, y + 5)); diff --git a/src/main/java/noppes/npcs/client/gui/mainmenu/GuiNpcAdvanced.java b/src/main/java/noppes/npcs/client/gui/mainmenu/GuiNpcAdvanced.java index 4cd861448..885c61d94 100644 --- a/src/main/java/noppes/npcs/client/gui/mainmenu/GuiNpcAdvanced.java +++ b/src/main/java/noppes/npcs/client/gui/mainmenu/GuiNpcAdvanced.java @@ -49,7 +49,7 @@ public class GuiNpcAdvanced extends GuiNPCInterface2 implements IGuiData { super.initGui(); int y = guiTop + 10; this.addButton(new GuiNpcButton(3, guiLeft + 85 + 160, y, 52, 20, "selectServer.edit")); - this.addButton(new GuiNpcButton(8, guiLeft + 85, y, 155, 20, new String[]{"role.none", "role.trader", "role.follower", "role.bank", "role.transporter", "role.mailman", NoppesStringUtils.translate("role.companion", "(WIP)"), "role.mount"}, npc.advanced.role.ordinal())); + this.addButton(new GuiNpcButton(8, guiLeft + 85, y, 155, 20, new String[]{"role.none", "role.trader", "role.follower", "role.bank", "role.transporter", "role.mailman", NoppesStringUtils.translate("role.companion", "(WIP)"), "role.mount", "role.auctioneer"}, npc.advanced.role.ordinal())); getButton(3).setEnabled(npc.advanced.role != EnumRoleType.None && npc.advanced.role != EnumRoleType.Postman); this.addButton(new GuiNpcButton(4, guiLeft + 85 + 160, y += 22, 52, 20, "selectServer.edit")); diff --git a/src/main/java/noppes/npcs/client/gui/player/GuiNPCTrader.java b/src/main/java/noppes/npcs/client/gui/player/GuiNPCTrader.java index d23b15398..f11bbb2e6 100644 --- a/src/main/java/noppes/npcs/client/gui/player/GuiNPCTrader.java +++ b/src/main/java/noppes/npcs/client/gui/player/GuiNPCTrader.java @@ -1,136 +1,335 @@ package noppes.npcs.client.gui.player; +import kamkeel.npcs.network.PacketClient; +import kamkeel.npcs.network.packets.player.GetTraderData; import net.minecraft.client.renderer.RenderHelper; import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; import net.minecraft.util.ResourceLocation; import net.minecraft.util.StatCollector; import noppes.npcs.NoppesUtilPlayer; import noppes.npcs.client.CustomNpcResourceListener; import noppes.npcs.client.gui.util.GuiContainerNPCInterface; +import noppes.npcs.client.gui.util.IGuiData; import noppes.npcs.containers.ContainerNPCTrader; import noppes.npcs.entity.EntityNPCInterface; import noppes.npcs.roles.RoleTrader; import org.lwjgl.opengl.GL11; import org.lwjgl.opengl.GL12; -public class GuiNPCTrader extends GuiContainerNPCInterface { - private final ResourceLocation resource = new ResourceLocation("customnpcs", "textures/gui/trader.png"); +public class GuiNPCTrader extends GuiContainerNPCInterface implements IGuiData { + private final ResourceLocation resource = new ResourceLocation("customnpcs", "textures/gui/tradersetup.png"); private final ResourceLocation slot = new ResourceLocation("customnpcs", "textures/gui/slot.png"); private RoleTrader role; private ContainerNPCTrader container; + // Layout constants matching ContainerNPCTrader + private static final int COLUMN_WIDTH = ContainerNPCTrader.COLUMN_WIDTH; + private static final int COLUMN_START_X = ContainerNPCTrader.COLUMN_START_X; + private static final int ROW_HEIGHT = ContainerNPCTrader.ROW_HEIGHT; + private static final int ROW_START_Y = ContainerNPCTrader.ROW_START_Y; + private static final int CURRENCY1_OFFSET = ContainerNPCTrader.CURRENCY1_OFFSET; + private static final int CURRENCY2_OFFSET = ContainerNPCTrader.CURRENCY2_OFFSET; + private static final int OUTPUT_OFFSET = ContainerNPCTrader.OUTPUT_OFFSET; + + // Data received from server via IGuiData + private long playerBalance = 0; + private int[] availableStock = new int[18]; + private long[] currencyCost = new long[18]; + private boolean stockEnabled = false; + + // Client-side countdown: target time when stock resets (System.currentTimeMillis()) + private long resetTargetTime = -1; + + // Current hover status for bottom display + private String statusMessage = ""; + private int statusColor = 0xFFFFFF; + public GuiNPCTrader(EntityNPCInterface npc, ContainerNPCTrader container) { super(npc, container); this.container = container; role = (RoleTrader) npc.roleInterface; closeOnEsc = true; - ySize = 224; - xSize = 223; - this.title = "role.trader"; + ySize = 216; + xSize = 256; + this.title = ""; + + // Initialize stock to unlimited + for (int i = 0; i < 18; i++) { + availableStock[i] = Integer.MAX_VALUE; + currencyCost[i] = 0; + } + + // Request trader data from server (balance, stock, currency costs) + PacketClient.sendClient(new GetTraderData()); } @Override - protected void drawGuiContainerBackgroundLayer(float f, int i, int j) { + public void setGuiData(NBTTagCompound compound) { + playerBalance = compound.getLong("Balance"); + stockEnabled = compound.getBoolean("StockEnabled"); + + // Calculate target time for client-side countdown + long remainingMillis = compound.getLong("ResetTimeMillis"); + if (remainingMillis > 0) { + resetTargetTime = System.currentTimeMillis() + remainingMillis; + } else { + resetTargetTime = -1; + } + + int[] stock = compound.getIntArray("Stock"); + if (stock != null && stock.length == 18) { + availableStock = stock; + } + + for (int i = 0; i < 18; i++) { + currencyCost[i] = compound.getLong("Cost" + i); + } + } + + @Override + protected void drawGuiContainerBackgroundLayer(float f, int mouseX, int mouseY) { + // Reset status each frame + statusMessage = ""; + statusColor = 0xFFFFFF; + this.drawWorldBackground(0); GL11.glColor4f(1.0F, 1.0F, 1.0F, 1.0F); mc.renderEngine.bindTexture(resource); drawTexturedModalRect(guiLeft, guiTop, 0, 0, xSize, ySize); + + // Draw "Trader" label in TOP LEFT (above the GUI) + String traderLabel = StatCollector.translateToLocal("role.trader"); + fontRendererObj.drawString(traderLabel, guiLeft + 4, guiTop - 8, 0xFFFFFF); + + // Draw reset timer in TOP RIGHT (above the GUI) + if (stockEnabled && resetTargetTime > 0) { + String timerText = getResetCountdownText(); + int timerWidth = fontRendererObj.getStringWidth(timerText); + int timerColor = timerText.equals(StatCollector.translateToLocal("trader.reopentrader")) ? 0xFFAA00 : 0xFFFFFF; + fontRendererObj.drawString(timerText, guiLeft + xSize - timerWidth - 4, guiTop - 8, timerColor); + } + RenderHelper.enableGUIStandardItemLighting(); GL11.glEnable(GL12.GL_RESCALE_NORMAL); GL11.glEnable(GL11.GL_COLOR_MATERIAL); GL11.glEnable(GL11.GL_LIGHTING); - mc.renderEngine.bindTexture(slot); - for (int slot = 0; slot < 18; slot++) { - int x = guiLeft + slot % 3 * 72 + 10; - int y = guiTop + slot / 3 * 21 + 6; + // Draw trade slots (3 columns x 6 rows, 80px column width) + for (int slotIdx = 0; slotIdx < 18; slotIdx++) { + int col = slotIdx % 3; + int row = slotIdx / 3; + int x = guiLeft + COLUMN_START_X + col * COLUMN_WIDTH; + int y = guiTop + ROW_START_Y + row * ROW_HEIGHT; - ItemStack item = role.inventoryCurrency.items.get(slot); - ItemStack item2 = role.inventoryCurrency.items.get(slot + 18); + // Get item currencies + ItemStack item = role.inventoryCurrency.items.get(slotIdx); + ItemStack item2 = role.inventoryCurrency.items.get(slotIdx + 18); if (item == null) { item = item2; item2 = null; } - if (NoppesUtilPlayer.compareItems(item, item2, false, false)) { + if (item != null && item2 != null && NoppesUtilPlayer.compareItems(item, item2, role.ignoreDamage, role.ignoreNBT)) { item = item.copy(); item.stackSize += item2.stackSize; item2 = null; } - ItemStack sold = role.inventorySold.items.get(slot); + ItemStack sold = role.inventorySold.items.get(slotIdx); + + // Draw slot backgrounds (18x18 each) GL11.glColor4f(1, 1, 1, 1); mc.renderEngine.bindTexture(this.slot); - drawTexturedModalRect(x + 42, y, 0, 0, 18, 18); - if (item != null && sold != null) { + + // Currency slot 1 + drawTexturedModalRect(x + CURRENCY1_OFFSET, y, 0, 0, 18, 18); + // Currency slot 2 + drawTexturedModalRect(x + CURRENCY2_OFFSET, y, 0, 0, 18, 18); + // Output slot + drawTexturedModalRect(x + OUTPUT_OFFSET, y, 0, 0, 18, 18); + + // "=" sign between currency and output + fontRendererObj.drawString("=", x + 40, y + 5, CustomNpcResourceListener.DefaultTextColor); + + // Draw currency items (centered: +1 offset for 16x16 item in 18x18 slot) + if (sold != null) { RenderHelper.enableGUIStandardItemLighting(); + // Draw secondary currency (slot 1) if (item2 != null) { - itemRender.renderItemAndEffectIntoGUI(fontRendererObj, mc.renderEngine, item2, x, y + 1); - itemRender.renderItemOverlayIntoGUI(fontRendererObj, mc.renderEngine, item2, x, y + 1); + itemRender.renderItemAndEffectIntoGUI(fontRendererObj, mc.renderEngine, item2, x + CURRENCY1_OFFSET + 1, y + 1); + itemRender.renderItemOverlayIntoGUI(fontRendererObj, mc.renderEngine, item2, x + CURRENCY1_OFFSET + 1, y + 1); } - GL11.glColor4f(1, 1, 1, 1); - itemRender.renderItemAndEffectIntoGUI(fontRendererObj, mc.renderEngine, item, x + 18, y + 1); - itemRender.renderItemOverlayIntoGUI(fontRendererObj, mc.renderEngine, item, x + 18, y + 1); + + // Draw primary currency (slot 2) + if (item != null) { + GL11.glColor4f(1, 1, 1, 1); + itemRender.renderItemAndEffectIntoGUI(fontRendererObj, mc.renderEngine, item, x + CURRENCY2_OFFSET + 1, y + 1); + itemRender.renderItemOverlayIntoGUI(fontRendererObj, mc.renderEngine, item, x + CURRENCY2_OFFSET + 1, y + 1); + } + RenderHelper.disableStandardItemLighting(); - fontRendererObj.drawString("=", x + 36, y + 5, CustomNpcResourceListener.DefaultTextColor); + // Draw stock count to the RIGHT of the trade (after output slot) + if (stockEnabled && availableStock[slotIdx] < Integer.MAX_VALUE) { + String stockText = String.valueOf(availableStock[slotIdx]); + int stockColor = availableStock[slotIdx] > 0 ? 0x00AA00 : 0xAA0000; + fontRendererObj.drawString(stockText, x + OUTPUT_OFFSET + 20, y + 5, stockColor); + } + // Draw dark overlay for out of stock items + if (stockEnabled && availableStock[slotIdx] <= 0) { + drawGradientRect(x + OUTPUT_OFFSET + 1, y + 1, x + OUTPUT_OFFSET + 17, y + 17, 0xC0101010, 0xC0101010); + } } } + GL11.glEnable(GL11.GL_LIGHTING); GL11.glEnable(GL11.GL_DEPTH_TEST); RenderHelper.enableStandardItemLighting(); - super.drawGuiContainerBackgroundLayer(f, i, j); + + super.drawGuiContainerBackgroundLayer(f, mouseX, mouseY); + + // Draw player balance (BOTTOM RIGHT, below GUI) + int infoY = guiTop + ySize + 2; + String balanceText = StatCollector.translateToLocal("trader.balance") + ": $" + formatCurrency(playerBalance); + int balanceWidth = fontRendererObj.getStringWidth(balanceText); + fontRendererObj.drawString(balanceText, guiLeft + xSize - balanceWidth - 4, infoY, 0x36e82a); } @Override - protected void drawGuiContainerForegroundLayer(int par1, int par2) { - for (int slot = 0; slot < 18; slot++) { - int x = slot % 3 * 72 + 10; - int y = slot / 3 * 21 + 6; + protected void drawGuiContainerForegroundLayer(int mouseX, int mouseY) { + for (int slotIdx = 0; slotIdx < 18; slotIdx++) { + int col = slotIdx % 3; + int row = slotIdx / 3; + int x = COLUMN_START_X + col * COLUMN_WIDTH; + int y = ROW_START_Y + row * ROW_HEIGHT; - ItemStack item = role.inventoryCurrency.items.get(slot); - ItemStack item2 = role.inventoryCurrency.items.get(slot + 18); + ItemStack item = role.inventoryCurrency.items.get(slotIdx); + ItemStack item2 = role.inventoryCurrency.items.get(slotIdx + 18); if (item == null) { item = item2; item2 = null; } - if (NoppesUtilPlayer.compareItems(item, item2, role.ignoreDamage, role.ignoreNBT)) { + if (item != null && item2 != null && NoppesUtilPlayer.compareItems(item, item2, role.ignoreDamage, role.ignoreNBT)) { item = item.copy(); item.stackSize += item2.stackSize; item2 = null; } - ItemStack sold = role.inventorySold.items.get(slot); - if (item == null || sold == null) + + ItemStack sold = role.inventorySold.items.get(slotIdx); + if (sold == null) { continue; - - if (this.func_146978_c(x + 43, y + 1, 16, 16, par1, par2)) { - if (!container.canBuy(slot, player)) { - GL11.glTranslatef(0, 0, 300); - if (item != null && !NoppesUtilPlayer.compareItems(player, item, role.ignoreDamage, role.ignoreNBT)) - this.drawGradientRect(x + 17, y, x + 35, y + 18, 0x70771010, 0x70771010); - if (item2 != null && !NoppesUtilPlayer.compareItems(player, item2, role.ignoreDamage, role.ignoreNBT)) - this.drawGradientRect(x - 1, y, x + 17, y + 18, 0x70771010, 0x70771010); - - String title = StatCollector.translateToLocal("trader.insufficient"); - this.fontRendererObj.drawString(title, (xSize - fontRendererObj.getStringWidth(title)) / 2, 131, 0xDD0000); - GL11.glTranslatef(0, 0, -300); - } else if (!container.isSlotEnabled(slot, player)) { - String title = StatCollector.translateToLocal("trader.slotdisabled"); - this.fontRendererObj.drawString(title, (xSize - fontRendererObj.getStringWidth(title)) / 2, 131, 0xFF4000); - } else { - String title = StatCollector.translateToLocal("trader.sufficient"); - this.fontRendererObj.drawString(title, (xSize - fontRendererObj.getStringWidth(title)) / 2, 131, 0x00DD00); - } } - if (this.func_146978_c(x, y, 16, 16, par1, par2) && item2 != null) { - this.renderToolTip(item2, par1 - guiLeft, par2 - guiTop); + // Check if hovering over output slot (18x18 area) + if (this.func_146978_c(x + OUTPUT_OFFSET, y, 18, 18, mouseX, mouseY)) { + updateSlotStatus(slotIdx, item, item2, x, y); } - if (this.func_146978_c(x + 18, y, 16, 16, par1, par2)) { - this.renderToolTip(item, par1 - guiLeft, par2 - guiTop); + + // Tooltips for currency items + if (this.func_146978_c(x + CURRENCY1_OFFSET, y, 18, 18, mouseX, mouseY) && item2 != null) { + this.renderToolTip(item2, mouseX - guiLeft, mouseY - guiTop); + } + if (this.func_146978_c(x + CURRENCY2_OFFSET, y, 18, 18, mouseX, mouseY) && item != null) { + this.renderToolTip(item, mouseX - guiLeft, mouseY - guiTop); } } + + // Draw status message in BOTTOM LEFT (in foreground coordinates) + if (!statusMessage.isEmpty()) { + fontRendererObj.drawString(statusMessage, 4, ySize + 4, statusColor); + } + } + + /** + * Update status message when hovering over a trade slot + */ + private void updateSlotStatus(int slotIdx, ItemStack item, ItemStack item2, int x, int y) { + long cost = currencyCost[slotIdx]; + String costSuffix = cost > 0 ? " ($" + formatCurrency(cost) + ")" : ""; + + // Check conditions in order of priority + if (stockEnabled && availableStock[slotIdx] <= 0) { + // Out of stock + statusMessage = StatCollector.translateToLocal("trader.outofstock") + costSuffix; + statusColor = 0xDD0000; + } else if (cost > 0 && playerBalance < cost) { + // Can't afford currency cost + statusMessage = StatCollector.translateToLocal("trader.insufficient.currency") + costSuffix; + statusColor = 0xDD0000; + } else if (!container.canBuy(slotIdx, player)) { + // Missing item currency - highlight which ones + GL11.glTranslatef(0, 0, 300); + if (item != null && !NoppesUtilPlayer.compareItems(player, item, role.ignoreDamage, role.ignoreNBT)) { + this.drawGradientRect(x + CURRENCY2_OFFSET, y, x + CURRENCY2_OFFSET + 18, y + 18, 0x70771010, 0x70771010); + } + if (item2 != null && !NoppesUtilPlayer.compareItems(player, item2, role.ignoreDamage, role.ignoreNBT)) { + this.drawGradientRect(x + CURRENCY1_OFFSET, y, x + CURRENCY1_OFFSET + 18, y + 18, 0x70771010, 0x70771010); + } + GL11.glTranslatef(0, 0, -300); + statusMessage = StatCollector.translateToLocal("trader.unavailable") + costSuffix; + statusColor = 0xDD0000; + } else if (!container.isSlotEnabled(slotIdx, player)) { + // Slot disabled + statusMessage = StatCollector.translateToLocal("trader.slotdisabled") + costSuffix; + statusColor = 0xFF4000; + } else { + // Can buy! + statusMessage = StatCollector.translateToLocal("trader.available") + costSuffix; + statusColor = 0x00DD00; + } + } + + /** + * Get the countdown text for stock reset timer. + * Calculated client-side each frame for smooth countdown. + */ + private String getResetCountdownText() { + if (resetTargetTime <= 0) { + return ""; + } + + long remaining = resetTargetTime - System.currentTimeMillis(); + if (remaining <= 0) { + return StatCollector.translateToLocal("trader.reopentrader"); + } + + // Format as HH:MM:SS or D days HH:MM:SS + long seconds = (remaining / 1000) % 60; + long minutes = (remaining / 60000) % 60; + long hours = (remaining / 3600000) % 24; + long days = remaining / 86400000; + + StringBuilder sb = new StringBuilder(); + sb.append(StatCollector.translateToLocal("trader.restock")).append(": "); + + if (days > 0) { + sb.append(days).append("d "); + } + sb.append(String.format("%02d:%02d:%02d", hours, minutes, seconds)); + + return sb.toString(); + } + + /** + * Format currency for display (e.g., 1000 -> "1,000") + */ + 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(); } @Override diff --git a/src/main/java/noppes/npcs/client/gui/roles/GuiNpcTraderSetup.java b/src/main/java/noppes/npcs/client/gui/roles/GuiNpcTraderSetup.java index 43c259da7..6c4745d42 100644 --- a/src/main/java/noppes/npcs/client/gui/roles/GuiNpcTraderSetup.java +++ b/src/main/java/noppes/npcs/client/gui/roles/GuiNpcTraderSetup.java @@ -8,7 +8,7 @@ import net.minecraft.nbt.NBTTagCompound; import net.minecraft.util.ResourceLocation; import noppes.npcs.client.CustomNpcResourceListener; import noppes.npcs.client.gui.util.GuiContainerNPCInterface2; -import noppes.npcs.client.gui.util.GuiNpcButtonYesNo; +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; @@ -22,6 +22,9 @@ public class GuiNpcTraderSetup extends GuiContainerNPCInterface2 implements ITex private final ResourceLocation slot = new ResourceLocation("customnpcs", "textures/gui/slot.png"); private RoleTrader role; + // Currency cost textfield IDs start at 100 (100-117 for slots 0-17) + private static final int CURRENCY_FIELD_ID_START = 100; + public GuiNpcTraderSetup(EntityNPCInterface npc, ContainerNPCTraderSetup container) { super(npc, container); ySize = 220; @@ -34,17 +37,30 @@ public class GuiNpcTraderSetup extends GuiContainerNPCInterface2 implements ITex super.initGui(); buttonList.clear(); setBackground("tradersetup.png"); + + // Linked Market name addLabel(new GuiNpcLabel(0, "role.marketname", guiLeft + 214, guiTop + 150)); addTextField(new GuiNpcTextField(0, this, guiLeft + 214, guiTop + 160, 180, 20, role.marketName)); - addLabel(new GuiNpcLabel(1, "gui.ignoreDamage", guiLeft + 260, guiTop + 29)); - addButton(new GuiNpcButtonYesNo(1, guiLeft + 340, guiTop + 24, role.ignoreDamage)); + // Settings button (opens SubGuiNpcTraderSettings) + addButton(new GuiNpcButton(1, guiLeft + 214, guiTop + 184, 88, 20, "gui.settings")); + getButton(1).setHoverText("gui.settings.hover"); - addLabel(new GuiNpcLabel(2, "gui.ignoreNBT", guiLeft + 260, guiTop + 51)); - addButton(new GuiNpcButtonYesNo(2, guiLeft + 340, guiTop + 46, role.ignoreNBT)); + // Stock Options button (opens SubGuiNpcTraderStock) + addButton(new GuiNpcButton(2, guiLeft + 306, guiTop + 184, 88, 20, "stock.options")); + getButton(2).setHoverText("stock.options.hover"); - addLabel(new GuiNpcLabel(3, "gui.recordHistory", guiLeft + 260, guiTop + 73)); - addButton(new GuiNpcButtonYesNo(3, guiLeft + 340, guiTop + 68, role.recordHistory)); + // Currency cost textfields for each slot (FIRST - before item slots) + // Column width: 130px for better spacing + for (int i = 0; i < 18; i++) { + int x = guiLeft + i % 3 * 130 + 15; // First position after "$" label + int y = guiTop + i / 3 * 22 + 14; + long cost = role.getCurrencyCost(i); + GuiNpcTextField field = new GuiNpcTextField(CURRENCY_FIELD_ID_START + i, this, x, y, 36, 16, cost > 0 ? String.valueOf(cost) : ""); + field.setMaxStringLength(8); + field.setIntegersOnly(); + addTextField(field); + } } @Override @@ -57,13 +73,12 @@ public class GuiNpcTraderSetup extends GuiContainerNPCInterface2 implements ITex @Override public void actionPerformed(GuiButton guibutton) { if (guibutton.id == 1) { - role.ignoreDamage = ((GuiNpcButtonYesNo) guibutton).getBoolean(); + // Open Settings SubGui + setSubGui(new SubGuiNpcTraderSettings(role)); } if (guibutton.id == 2) { - role.ignoreNBT = ((GuiNpcButtonYesNo) guibutton).getBoolean(); - } - if (guibutton.id == 3) { - role.recordHistory = ((GuiNpcButtonYesNo) guibutton).getBoolean(); + // Open Stock Options SubGui + setSubGui(new SubGuiNpcTraderStock(role)); } } @@ -71,19 +86,31 @@ public class GuiNpcTraderSetup extends GuiContainerNPCInterface2 implements ITex protected void drawGuiContainerBackgroundLayer(float f, int i, int j) { super.drawGuiContainerBackgroundLayer(f, i, j); GL11.glColor4f(1.0F, 1.0F, 1.0F, 1.0F); + // Column width: 130px for better spacing for (int slot = 0; slot < 18; slot++) { - int x = guiLeft + slot % 3 * 94 + 7; + int x = guiLeft + slot % 3 * 130 + 7; int y = guiTop + slot / 3 * 22 + 4; - mc.renderEngine.bindTexture(this.slot); - GL11.glColor4f(1, 1, 1, 1); - drawTexturedModalRect(x - 1, y, 0, 0, 18, 18); - drawTexturedModalRect(x + 17, y, 0, 0, 18, 18); + // "$" label for currency cost + fontRendererObj.drawString("$", x, y + 5, CustomNpcResourceListener.DefaultTextColor); - fontRendererObj.drawString("=", x + 36, y + 5, CustomNpcResourceListener.DefaultTextColor); + // Currency textfield is at x + 8 (drawn by textfield system) + + // Item Cost 1 slot mc.renderEngine.bindTexture(this.slot); GL11.glColor4f(1, 1, 1, 1); - drawTexturedModalRect(x + 42, y, 0, 0, 18, 18); + drawTexturedModalRect(x + 50, y, 0, 0, 18, 18); + + // Item Cost 2 slot + drawTexturedModalRect(x + 68, y, 0, 0, 18, 18); + + // "=" sign + fontRendererObj.drawString("=", x + 88, y + 5, CustomNpcResourceListener.DefaultTextColor); + + // Output slot + mc.renderEngine.bindTexture(this.slot); + GL11.glColor4f(1, 1, 1, 1); + drawTexturedModalRect(x + 104, y, 0, 0, 18, 18); } } @@ -95,10 +122,23 @@ public class GuiNpcTraderSetup extends GuiContainerNPCInterface2 implements ITex @Override public void unFocused(GuiNpcTextField guiNpcTextField) { - String name = guiNpcTextField.getText(); - if (!name.equalsIgnoreCase(role.marketName)) { - role.marketName = name; - PacketClient.sendClient(new TraderMarketSavePacket(role.marketName, true)); + int id = guiNpcTextField.id; + + // Handle market name field + if (id == 0) { + String name = guiNpcTextField.getText(); + if (!name.equalsIgnoreCase(role.marketName)) { + role.marketName = name; + PacketClient.sendClient(new TraderMarketSavePacket(role.marketName, true)); + } + return; + } + + // Handle currency cost fields (IDs 100-117) + if (id >= CURRENCY_FIELD_ID_START && id < CURRENCY_FIELD_ID_START + 18) { + int slot = id - CURRENCY_FIELD_ID_START; + long cost = guiNpcTextField.getInteger(); + role.setCurrencyCost(slot, cost); } } } diff --git a/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderCooldownReset.java b/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderCooldownReset.java new file mode 100644 index 000000000..4c72e2059 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderCooldownReset.java @@ -0,0 +1,52 @@ +package noppes.npcs.client.gui.roles; + +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.SubGuiInterface; +import noppes.npcs.roles.RoleTrader; + +/** + * Confirmation dialog for resetting trader stock cooldown. + */ +public class SubGuiNpcTraderCooldownReset extends SubGuiInterface { + + private final RoleTrader role; + public boolean confirmed = false; + + public SubGuiNpcTraderCooldownReset(RoleTrader role) { + this.role = role; + setBackground("menubg.png"); + xSize = 200; + ySize = 100; + closeOnEsc = true; + } + + @Override + public void initGui() { + super.initGui(); + + int y = guiTop + 15; + + addLabel(new GuiNpcLabel(0, "stock.cooldown.confirm", guiLeft + 10, y)); + y += 12; + addLabel(new GuiNpcLabel(1, "stock.cooldown.warning", guiLeft + 10, y)); + + y += 35; + + addButton(new GuiNpcButton(0, guiLeft + 30, y, 60, 20, "gui.yes")); + addButton(new GuiNpcButton(1, guiLeft + 110, y, 60, 20, "gui.no")); + } + + @Override + public void buttonEvent(GuiButton guibutton) { + if (guibutton.id == 0) { + // Reset the cooldown + role.resetCooldown(); + confirmed = true; + close(); + } else if (guibutton.id == 1) { + close(); + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderSettings.java b/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderSettings.java new file mode 100644 index 000000000..d9e2a0b40 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderSettings.java @@ -0,0 +1,66 @@ +package noppes.npcs.client.gui.roles; + +import net.minecraft.client.gui.GuiButton; +import noppes.npcs.client.gui.util.GuiNpcButton; +import noppes.npcs.client.gui.util.GuiNpcButtonYesNo; +import noppes.npcs.client.gui.util.GuiNpcLabel; +import noppes.npcs.client.gui.util.SubGuiInterface; +import noppes.npcs.roles.RoleTrader; + +public class SubGuiNpcTraderSettings extends SubGuiInterface { + + private final RoleTrader role; + + public SubGuiNpcTraderSettings(RoleTrader role) { + this.role = role; + setBackground("menubg.png"); + xSize = 176; + ySize = 136; + closeOnEsc = true; + } + + @Override + public void initGui() { + super.initGui(); + + int y = guiTop + 14; + + addLabel(new GuiNpcLabel(1, "gui.ignoreDamage", guiLeft + 10, y + 5)); + addButton(new GuiNpcButtonYesNo(1, guiLeft + 120, y, role.ignoreDamage)); + getButton(1).setHoverText("gui.ignoreDamage.hover"); + + y += 28; + + addLabel(new GuiNpcLabel(2, "gui.ignoreNBT", guiLeft + 10, y + 5)); + addButton(new GuiNpcButtonYesNo(2, guiLeft + 120, y, role.ignoreNBT)); + getButton(2).setHoverText("gui.ignoreNBT.hover"); + + y += 28; + + addLabel(new GuiNpcLabel(3, "gui.recordHistory", guiLeft + 10, y + 5)); + addButton(new GuiNpcButtonYesNo(3, guiLeft + 120, y, role.recordHistory)); + getButton(3).setHoverText("gui.recordHistory.hover"); + + y += 34; + + addButton(new GuiNpcButton(0, guiLeft + (xSize - 60) / 2, y, 60, 20, "gui.done")); + } + + @Override + public void buttonEvent(GuiButton button) { + switch (button.id) { + case 0: + close(); + break; + case 1: + role.ignoreDamage = ((GuiNpcButtonYesNo) button).getBoolean(); + break; + case 2: + role.ignoreNBT = ((GuiNpcButtonYesNo) button).getBoolean(); + break; + case 3: + role.recordHistory = ((GuiNpcButtonYesNo) button).getBoolean(); + break; + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderStock.java b/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderStock.java new file mode 100644 index 000000000..2e369b7e6 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderStock.java @@ -0,0 +1,159 @@ +package noppes.npcs.client.gui.roles; + +import net.minecraft.client.gui.GuiButton; +import noppes.npcs.client.gui.util.GuiNpcButton; +import noppes.npcs.client.gui.util.GuiNpcButtonYesNo; +import noppes.npcs.client.gui.util.GuiNpcLabel; +import noppes.npcs.client.gui.util.GuiNpcTextField; +import noppes.npcs.client.gui.util.ISubGuiListener; +import noppes.npcs.client.gui.util.ITextfieldListener; +import noppes.npcs.client.gui.util.SubGuiInterface; +import noppes.npcs.client.gui.util.SubGuiNpcCooldownPicker; +import noppes.npcs.constants.EnumStockReset; +import noppes.npcs.roles.RoleTrader; + +public class SubGuiNpcTraderStock extends SubGuiInterface implements ISubGuiListener, ITextfieldListener { + + private final RoleTrader role; + + public SubGuiNpcTraderStock(RoleTrader role) { + this.role = role; + setBackground("menubg.png"); + xSize = 220; + ySize = 220; + closeOnEsc = true; + } + + @Override + public void initGui() { + super.initGui(); + + int y = guiTop + 10; + + // Enable Stock + addLabel(new GuiNpcLabel(1, "stock.enable", guiLeft + 10, y + 5)); + addButton(new GuiNpcButtonYesNo(1, guiLeft + 140, y, role.stock.enableStock)); + getButton(1).setHoverText("stock.enable.hover"); + + y += 24; + + // Per-Player Stock + addLabel(new GuiNpcLabel(2, "stock.perplayer", guiLeft + 10, y + 5)); + addButton(new GuiNpcButtonYesNo(2, guiLeft + 140, y, role.stock.perPlayer)); + getButton(2).setHoverText("stock.perplayer.hover"); + + y += 24; + + // Reset Type + addLabel(new GuiNpcLabel(3, "stock.resettype", guiLeft + 10, y + 5)); + addButton(new GuiNpcButton(3, guiLeft + 90, y, 120, 20, EnumStockReset.getDisplayNames(), role.stock.resetType.ordinal())); + getButton(3).setHoverText("stock.resettype.hover"); + + y += 24; + + // Custom Time button (only visible for custom types) + EnumStockReset resetType = role.stock.resetType; + if (resetType == EnumStockReset.MCCUSTOM || resetType == EnumStockReset.RLCUSTOM) { + addLabel(new GuiNpcLabel(4, "stock.customtime", guiLeft + 10, y + 5)); + addButton(new GuiNpcButton(4, guiLeft + 90, y, 120, 20, "gui.edit")); + getButton(4).setHoverText("stock.customtime.hover"); + y += 24; + } + + // Default Max Stock (applies to all slots as default) + addLabel(new GuiNpcLabel(5, "stock.defaultmax", guiLeft + 10, y + 5)); + GuiNpcTextField defaultStock = new GuiNpcTextField(5, this, fontRendererObj, guiLeft + 140, y, 60, 20, getDefaultStockString()); + defaultStock.setIntegersOnly(); + defaultStock.setMinMaxDefault(-1, Integer.MAX_VALUE, -1); + addTextField(defaultStock); + + y += 26; + + // Per-Slot Stock button + addButton(new GuiNpcButton(5, guiLeft + 10, y, 95, 20, "stock.perslot")); + getButton(5).setHoverText("stock.perslot.hover"); + + // Reset Stock button + addButton(new GuiNpcButton(6, guiLeft + 115, y, 95, 20, "stock.reset")); + getButton(6).setHoverText("stock.reset.hover"); + + y += 24; + + // Reset Cooldown button + addButton(new GuiNpcButton(7, guiLeft + 10, y, 200, 20, "stock.resetcooldown")); + getButton(7).setHoverText("stock.resetcooldown.hover"); + + // Done button + addButton(new GuiNpcButton(0, guiLeft + (xSize - 60) / 2, guiTop + ySize - 40, 60, 20, "gui.done")); + } + + private String getDefaultStockString() { + // Check if all stocks are the same, if so return that value + int first = role.stock.maxStock[0]; + for (int i = 1; i < 18; i++) { + if (role.stock.maxStock[i] != first) { + return "-1"; // Mixed values, return unlimited + } + } + return String.valueOf(first); + } + + @Override + public void buttonEvent(GuiButton button) { + switch (button.id) { + case 0: // Done + close(); + break; + case 1: // Enable Stock + role.stock.enableStock = ((GuiNpcButtonYesNo) button).getBoolean(); + break; + case 2: // Per-Player + role.stock.perPlayer = ((GuiNpcButtonYesNo) button).getBoolean(); + break; + case 3: // Reset Type + int value = ((GuiNpcButton) button).getValue(); + role.stock.resetType = EnumStockReset.values()[value]; + initGui(); // Refresh to show/hide custom time button + break; + case 4: // Custom Time + boolean isMC = role.stock.resetType == EnumStockReset.MCCUSTOM; + setSubGui(new SubGuiNpcCooldownPicker(isMC, role.stock.customResetTime)); + break; + case 5: // Per-Slot Stock + setSubGui(new SubGuiNpcTraderStockSlots(role)); + break; + case 6: // Reset Stock + setSubGui(new SubGuiNpcTraderStockReset(role)); + break; + case 7: // Reset Cooldown + setSubGui(new SubGuiNpcTraderCooldownReset(role)); + break; + } + } + + @Override + public void subGuiClosed(SubGuiInterface subGui) { + if (subGui instanceof SubGuiNpcCooldownPicker) { + long value = ((SubGuiNpcCooldownPicker) subGui).cooldownValue; + // Validate minimum: 1 tick for MC time, 1000ms (1 second) for real time + boolean isMC = role.stock.resetType == EnumStockReset.MCCUSTOM; + long minimum = isMC ? 1 : 1000; + role.stock.customResetTime = Math.max(minimum, value); + } + // Refresh after per-slot editing + if (subGui instanceof SubGuiNpcTraderStockSlots) { + initGui(); + } + } + + @Override + public void unFocused(GuiNpcTextField textField) { + if (textField.id == 5) { + int value = textField.getInteger(); + // Apply to all slots + for (int i = 0; i < 18; i++) { + role.stock.setMaxStock(i, value); + } + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderStockReset.java b/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderStockReset.java new file mode 100644 index 000000000..027108f76 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderStockReset.java @@ -0,0 +1,55 @@ +package noppes.npcs.client.gui.roles; + +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.SubGuiInterface; +import noppes.npcs.roles.RoleTrader; + +/** + * Confirmation dialog for resetting trader stock. + */ +public class SubGuiNpcTraderStockReset extends SubGuiInterface { + + private final RoleTrader role; + public boolean confirmed = false; + + public SubGuiNpcTraderStockReset(RoleTrader role) { + this.role = role; + setBackground("menubg.png"); + xSize = 200; + ySize = 100; + closeOnEsc = true; + } + + @Override + public void initGui() { + super.initGui(); + + int y = guiTop + 15; + + addLabel(new GuiNpcLabel(0, "stock.reset.confirm", guiLeft + 10, y)); + y += 12; + addLabel(new GuiNpcLabel(1, "stock.reset.warning", guiLeft + 10, y)); + + y += 35; + + addButton(new GuiNpcButton(0, guiLeft + 30, y, 60, 20, "gui.yes")); + addButton(new GuiNpcButton(1, guiLeft + 110, y, 60, 20, "gui.no")); + } + + @Override + public void buttonEvent(GuiButton guibutton) { + if (guibutton.id == 0) { + // Reset the stock + long currentTime = role.stock.resetType.isRealTime() + ? System.currentTimeMillis() + : (role.npc != null && role.npc.worldObj != null ? role.npc.worldObj.getTotalWorldTime() : 0); + role.stock.resetStock(currentTime); + confirmed = true; + close(); + } else if (guibutton.id == 1) { + close(); + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderStockSlots.java b/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderStockSlots.java new file mode 100644 index 000000000..b4a76ba48 --- /dev/null +++ b/src/main/java/noppes/npcs/client/gui/roles/SubGuiNpcTraderStockSlots.java @@ -0,0 +1,76 @@ +package noppes.npcs.client.gui.roles; + +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; +import noppes.npcs.roles.RoleTrader; + +/** + * Per-slot stock management dialog. + * Shows all 18 slots with their max stock values. + */ +public class SubGuiNpcTraderStockSlots extends SubGuiInterface implements ITextfieldListener { + + private final RoleTrader role; + + public SubGuiNpcTraderStockSlots(RoleTrader role) { + this.role = role; + setBackground("menubg.png"); + xSize = 256; + ySize = 216; + closeOnEsc = true; + } + + @Override + public void initGui() { + super.initGui(); + + // Title + addLabel(new GuiNpcLabel(0, "stock.perslot.title", guiLeft + 10, guiTop + 6)); + + // 18 slots in 3 columns x 6 rows + int startY = guiTop + 22; + int colWidth = 80; + + for (int i = 0; i < 18; i++) { + int col = i % 3; + int row = i / 3; + + int x = guiLeft + 10 + col * colWidth; + int y = startY + row * 28; + + // Slot label + addLabel(new GuiNpcLabel(i + 1, "Slot " + (i + 1) + ":", x, y + 5)); + + // Max stock textfield + int maxStock = role.stock.maxStock[i]; + String value = maxStock < 0 ? "-1" : String.valueOf(maxStock); + GuiNpcTextField field = new GuiNpcTextField(i, this, fontRendererObj, x + 42, y, 32, 18, value); + field.setIntegersOnly(); + field.setMinMaxDefault(-1, Integer.MAX_VALUE, -1); + addTextField(field); + } + + // Done button + addButton(new GuiNpcButton(0, guiLeft + (xSize - 60) / 2, guiTop + ySize - 26, 60, 20, "gui.done")); + } + + @Override + public void buttonEvent(GuiButton button) { + if (button.id == 0) { + close(); + } + } + + @Override + public void unFocused(GuiNpcTextField textField) { + int slot = textField.id; + if (slot >= 0 && slot < 18) { + int value = textField.getInteger(); + role.stock.setMaxStock(slot, value); + } + } +} diff --git a/src/main/java/noppes/npcs/client/gui/select/GuiAnimationSelection.java b/src/main/java/noppes/npcs/client/gui/select/GuiAnimationSelection.java index c0fbb0295..882f5817b 100644 --- a/src/main/java/noppes/npcs/client/gui/select/GuiAnimationSelection.java +++ b/src/main/java/noppes/npcs/client/gui/select/GuiAnimationSelection.java @@ -50,7 +50,7 @@ public class GuiAnimationSelection extends SubGuiInterface implements ICustomScr public void initGui() { super.initGui(); - this.addLabel(new GuiNpcLabel(0, "gui.animations", guiLeft + 8, guiTop + 4)); + this.addLabel(new GuiNpcLabel(0, "menu.animations", guiLeft + 8, guiTop + 4)); this.addButton(new GuiNpcButton(1, guiLeft + 4, guiTop + ySize - 35, 50, 20, "gui.clear")); this.addButton(new GuiNpcButton(2, guiLeft + xSize - 56, guiTop + ySize - 35, 50, 20, "gui.done")); diff --git a/src/main/java/noppes/npcs/client/gui/util/GuiContainerNPCInterface.java b/src/main/java/noppes/npcs/client/gui/util/GuiContainerNPCInterface.java index b4049bc8a..54ca9da5d 100644 --- a/src/main/java/noppes/npcs/client/gui/util/GuiContainerNPCInterface.java +++ b/src/main/java/noppes/npcs/client/gui/util/GuiContainerNPCInterface.java @@ -224,6 +224,15 @@ public abstract class GuiContainerNPCInterface extends GuiContainer { RenderHelper.disableStandardItemLighting(); subgui.drawScreen(i, j, f); } + + // Draw hover text for buttons + boolean subGui = hasSubGui(); + for (GuiCustomScroll scroll : new ArrayList(scrolls.values())) { + scroll.drawHover(i, j); + } + for (GuiNpcButton button : buttons.values()) { + button.drawHover(i, j, subGui); + } } @Override diff --git a/src/main/java/noppes/npcs/client/gui/util/GuiContainerNPCInterface2.java b/src/main/java/noppes/npcs/client/gui/util/GuiContainerNPCInterface2.java index 3455e4986..5e97ec19a 100644 --- a/src/main/java/noppes/npcs/client/gui/util/GuiContainerNPCInterface2.java +++ b/src/main/java/noppes/npcs/client/gui/util/GuiContainerNPCInterface2.java @@ -61,9 +61,9 @@ public abstract class GuiContainerNPCInterface2 extends GuiContainerNPCInterface GL11.glColor4f(1.0F, 1.0F, 1.0F, 1.0F); mc.renderEngine.bindTexture(defaultBackground2); if (this instanceof GuiNpcTraderSetup) - drawTexturedModalRect(guiLeft + xSize - 210, guiTop, 46, 0, 256, 220); + drawTexturedModalRect(guiLeft + xSize - 210, guiTop, 46, 0, 210, 220); else - drawTexturedModalRect(guiLeft + xSize - 230, guiTop, 26, 0, 256, 220); + drawTexturedModalRect(guiLeft + xSize - 230, guiTop, 26, 0, 230, 220); menu.drawElements(fontRendererObj, i, j, mc, f); diff --git a/src/main/java/noppes/npcs/client/gui/util/GuiNPCInterface.java b/src/main/java/noppes/npcs/client/gui/util/GuiNPCInterface.java index 37dce6f79..823f6a3be 100644 --- a/src/main/java/noppes/npcs/client/gui/util/GuiNPCInterface.java +++ b/src/main/java/noppes/npcs/client/gui/util/GuiNPCInterface.java @@ -421,10 +421,23 @@ public abstract class GuiNPCInterface extends GuiScreen { GL11.glScalef(bgScale * bgScaleX, bgScale * bgScaleY, bgScale * bgScaleZ); mc.renderEngine.bindTexture(background); if (xSize > 256) { + // GUI wider than texture: draw left portion, then right portion from texture edge drawTexturedModalRect(0, 0, 0, 0, 250, ySize); drawTexturedModalRect(250, 0, 256 - (xSize - 250), 0, xSize - 250, ySize); - } else + } else if (xSize < 256) { + // GUI narrower than texture: stitch left and right edges together + // Left portion (first half of GUI) + int leftWidth = xSize / 2; + // Right portion width (remaining) + int rightWidth = xSize - leftWidth; + // Draw left side from texture start + drawTexturedModalRect(0, 0, 0, 0, leftWidth, ySize); + // Draw right side from texture end (256 - rightWidth) + drawTexturedModalRect(leftWidth, 0, 256 - rightWidth, 0, rightWidth, ySize); + } else { + // GUI exactly 256px: draw full texture drawTexturedModalRect(0, 0, 0, 0, xSize, ySize); + } GL11.glPopMatrix(); } diff --git a/src/main/java/noppes/npcs/config/ConfigMarket.java b/src/main/java/noppes/npcs/config/ConfigMarket.java new file mode 100644 index 000000000..c84ec3604 --- /dev/null +++ b/src/main/java/noppes/npcs/config/ConfigMarket.java @@ -0,0 +1,102 @@ +package noppes.npcs.config; + +import cpw.mods.fml.common.FMLLog; +import net.minecraftforge.common.config.Configuration; +import org.apache.logging.log4j.Level; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; + +public class ConfigMarket { + public static Configuration config; + + public final static String MARKET = "Market"; + public final static String CURRENCY = "Market.Currency"; + public final static String TRADER = "Market.Trader"; + + // ========================================= + // Market General + // ========================================= + public static boolean MarketEnabled = true; + + // ========================================= + // Currency Settings + // ========================================= + public static boolean UseVault = false; + public static String CurrencyName = "Coins"; + public static long StartingBalance = 0; + public static long MaxBalance = Long.MAX_VALUE; + + // ========================================= + // Trader Settings + // ========================================= + public static boolean EnableStockByDefault = false; + public static String DefaultResetType = "NONE"; + + public static void init(File configFile) { + config = new Configuration(configFile); + + try { + config.load(); + + // ========================================= + // Market General + // ========================================= + MarketEnabled = config.get(MARKET, "Enable Market System", true, + "Enable the market/economy system").getBoolean(true); + + // ========================================= + // Currency Settings + // ========================================= + config.setCategoryComment(CURRENCY, "CNPC+ Currency system settings. When UseVault is enabled, all currency operations use Vault instead of the built-in system."); + + UseVault = config.get(CURRENCY, "Use Vault", false, + "If true, use Vault API for all currency operations instead of CNPC+ built-in currency. The built-in currency data will be preserved but unused.").getBoolean(false); + + CurrencyName = config.get(CURRENCY, "Currency Name", "Coins", + "Display name for the currency (e.g. 'Coins', 'Gold', 'Credits')").getString(); + + StartingBalance = config.get(CURRENCY, "Starting Balance", 0, + "Starting currency balance for new players").getInt(0); + + MaxBalance = config.get(CURRENCY, "Max Balance", Integer.MAX_VALUE, + "Maximum currency balance a player can have").getInt(Integer.MAX_VALUE); + + // ========================================= + // Trader Settings + // ========================================= + config.setCategoryComment(TRADER, "Trader stock system settings"); + + EnableStockByDefault = config.get(TRADER, "Enable Stock By Default", false, + "Enable stock system by default for new traders").getBoolean(false); + + DefaultResetType = config.get(TRADER, "Default Reset Type", "NONE", + "Default stock reset type (NONE, MCDAILY, MCWEEKLY, RLDAILY, RLWEEKLY)").getString(); + + // Set category order + config.setCategoryPropertyOrder(MARKET, new ArrayList<>(Arrays.asList( + "Enable Market System" + ))); + + config.setCategoryPropertyOrder(CURRENCY, new ArrayList<>(Arrays.asList( + "Use Vault", + "Currency Name", + "Starting Balance", + "Max Balance" + ))); + + config.setCategoryPropertyOrder(TRADER, new ArrayList<>(Arrays.asList( + "Enable Stock By Default", + "Default Reset Type" + ))); + + } catch (Exception e) { + FMLLog.log(Level.ERROR, e, "CNPC+ has had a problem loading its market configuration"); + } finally { + if (config.hasChanged()) { + config.save(); + } + } + } +} diff --git a/src/main/java/noppes/npcs/config/LoadConfiguration.java b/src/main/java/noppes/npcs/config/LoadConfiguration.java index 19fad65c2..dfac43d90 100644 --- a/src/main/java/noppes/npcs/config/LoadConfiguration.java +++ b/src/main/java/noppes/npcs/config/LoadConfiguration.java @@ -9,6 +9,7 @@ public class LoadConfiguration { public static File debugConfigFile; public static File clientConfigFile; public static File experimentalConfigFile; + public static File marketConfigFile; public static void init(String configpath) { mainConfigFile = new File(configpath + "main.cfg"); @@ -17,6 +18,7 @@ public class LoadConfiguration { scriptConfigFile = new File(configpath + "script.cfg"); debugConfigFile = new File(configpath + "debug.cfg"); experimentalConfigFile = new File(configpath + "experimental.cfg"); + marketConfigFile = new File(configpath + "market.cfg"); ConfigMain.init(mainConfigFile); ConfigItem.init(itemConfigFile); @@ -24,5 +26,6 @@ public class LoadConfiguration { ConfigScript.init(scriptConfigFile); ConfigDebug.init(debugConfigFile); ConfigExperimental.init(experimentalConfigFile); + ConfigMarket.init(marketConfigFile); } } diff --git a/src/main/java/noppes/npcs/constants/EnumStockReset.java b/src/main/java/noppes/npcs/constants/EnumStockReset.java new file mode 100644 index 000000000..485318dd5 --- /dev/null +++ b/src/main/java/noppes/npcs/constants/EnumStockReset.java @@ -0,0 +1,75 @@ +package noppes.npcs.constants; + +import net.minecraft.util.StatCollector; + +/** + * Stock reset types for Trader NPCs. + * Mirrors EnumQuestRepeat pattern for consistency. + */ +public enum EnumStockReset { + NONE("stock.reset.never"), + MCDAILY("stock.reset.mcdaily"), + MCWEEKLY("stock.reset.mcweekly"), + MCCUSTOM("stock.reset.mccustom"), + RLDAILY("stock.reset.rldaily"), + RLWEEKLY("stock.reset.rlweekly"), + RLCUSTOM("stock.reset.rlcustom"); + + private final String langKey; + + EnumStockReset(String langKey) { + this.langKey = langKey; + } + + /** + * Get reset interval in milliseconds for real-time types + * or ticks for MC-time types + */ + public long getDefaultInterval() { + switch (this) { + case MCDAILY: + return 24000; // MC ticks + case MCWEEKLY: + return 168000; // MC ticks (7 days) + case RLDAILY: + return 86400000L; // 24 hours in ms + case RLWEEKLY: + return 604800000L; // 7 days in ms + default: + return 0; + } + } + + /** + * Check if this reset type uses real-world time + */ + public boolean isRealTime() { + return this == RLDAILY || this == RLWEEKLY || this == RLCUSTOM; + } + + /** + * Check if this reset type uses Minecraft time + */ + public boolean isMinecraftTime() { + return this == MCDAILY || this == MCWEEKLY || this == MCCUSTOM; + } + + /** + * Get display name for GUI (translated) + */ + public String getDisplayName() { + return StatCollector.translateToLocal(langKey); + } + + /** + * Get all display names for dropdown menus + */ + public static String[] getDisplayNames() { + EnumStockReset[] values = values(); + String[] names = new String[values.length]; + for (int i = 0; i < values.length; i++) { + names[i] = values[i].getDisplayName(); + } + return names; + } +} diff --git a/src/main/java/noppes/npcs/containers/ContainerNPCTrader.java b/src/main/java/noppes/npcs/containers/ContainerNPCTrader.java index 71dcd6fa6..e6b4c86c8 100644 --- a/src/main/java/noppes/npcs/containers/ContainerNPCTrader.java +++ b/src/main/java/noppes/npcs/containers/ContainerNPCTrader.java @@ -1,44 +1,71 @@ package noppes.npcs.containers; import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.entity.player.EntityPlayerMP; import net.minecraft.inventory.Slot; import net.minecraft.item.ItemStack; import noppes.npcs.NoppesUtilPlayer; +import noppes.npcs.controllers.data.PlayerData; import noppes.npcs.entity.EntityNPCInterface; import noppes.npcs.roles.RoleTrader; public class ContainerNPCTrader extends ContainerNpcInterface { public RoleTrader role; + private final EntityNPCInterface npc; + + // Layout constants for 256px wide GUI + public static final int COLUMN_WIDTH = 80; + public static final int COLUMN_START_X = 8; + public static final int ROW_HEIGHT = 22; + public static final int ROW_START_Y = 6; + + // Slot offsets within each column (for 18x18 slots, items render centered) + public static final int CURRENCY1_OFFSET = 2; // Primary currency slot + public static final int CURRENCY2_OFFSET = 20; // Secondary currency slot + public static final int OUTPUT_OFFSET = 50; // Output slot public ContainerNPCTrader(EntityNPCInterface npc, EntityPlayer player) { super(player); + this.npc = npc; role = (RoleTrader) npc.roleInterface; - for (int i = 0; i < 18; i++) { - int x = 53; - x += i % 3 * 72; - int y = 7; - y += i / 3 * 21; + // Register this player as viewing the trader (server-side only) + // Needed for shared stock (not per-player) to sync updates to other viewers + // Works for both linked markets (MarketRegistry) and normal traders (local viewers) + if (player instanceof EntityPlayerMP && !role.stock.perPlayer) { + role.registerViewer((EntityPlayerMP) player); + } - ItemStack item = role.inventoryCurrency.items.get(i); - ItemStack item2 = role.inventoryCurrency.items.get(i + 18); - if (item == null) { - item = item2; - item2 = null; - } - addSlotToContainer(new Slot(role.inventorySold, i, x, y)); + // Trade slots: 3 columns x 6 rows, 80px column width + // Layout per column: [Slot1][Slot2] = [Output] + for (int i = 0; i < 18; i++) { + int col = i % 3; + int row = i / 3; + int x = COLUMN_START_X + col * COLUMN_WIDTH; + int y = ROW_START_Y + row * ROW_HEIGHT; + // Output slot position (slot texture is 18x18, item renders centered at +1) + addSlotToContainer(new Slot(role.inventorySold, i, x + OUTPUT_OFFSET + 1, y + 1)); } for (int i1 = 0; i1 < 3; i1++) { for (int l1 = 0; l1 < 9; l1++) { - addSlotToContainer(new Slot(player.inventory, l1 + i1 * 9 + 9, 32 + l1 * 18, 140 + i1 * 18)); + addSlotToContainer(new Slot(player.inventory, l1 + i1 * 9 + 9, 48 + l1 * 18, 137 + i1 * 18)); } - } + // Player hotbar for (int j1 = 0; j1 < 9; j1++) { - addSlotToContainer(new Slot(player.inventory, j1, 32 + j1 * 18, 198)); + addSlotToContainer(new Slot(player.inventory, j1, 48 + j1 * 18, 195)); + } + } + + @Override + public void onContainerClosed(EntityPlayer player) { + super.onContainerClosed(player); + // Unregister this player from viewer tracking (only if registered for shared stock) + if (player instanceof EntityPlayerMP && !role.stock.perPlayer) { + role.unregisterViewer((EntityPlayerMP) player); } } @@ -61,17 +88,57 @@ public class ContainerNPCTrader extends ContainerNpcInterface { ItemStack item = slot.getStack(); if (!canGivePlayer(item, entityplayer)) return null; - if (!canBuy(i, entityplayer)) - return null; if (!isSlotEnabled(i, entityplayer)) return null; + + // Check stock availability + String playerName = entityplayer.getCommandSenderName(); + if (!role.hasStock(i, playerName, 1)) { + return null; // Out of stock + } + + // Item-based currency + if (!canBuy(i, entityplayer)) + return null; + + // Currency cost check (additive to item costs) + long currencyCost = role.getCurrencyCost(i); + if (currencyCost > 0) { + PlayerData data = PlayerData.get(entityplayer); + if (data.currencyData.getBalance() < currencyCost) { + return null; // Can't afford currency cost + } + } + + // Consume item currency NoppesUtilPlayer.consumeItem(entityplayer, role.inventoryCurrency.getStackInSlot(i), role.ignoreDamage, role.ignoreNBT); NoppesUtilPlayer.consumeItem(entityplayer, role.inventoryCurrency.getStackInSlot(i + 18), role.ignoreDamage, role.ignoreNBT); + + // Withdraw currency cost (after item consumption to maintain order) + if (currencyCost > 0) { + PlayerData data = PlayerData.get(entityplayer); + data.currencyData.withdraw(currencyCost); + } + + // Consume stock + role.consumeStock(i, playerName, 1); + ItemStack soldItem = item.copy(); givePlayer(soldItem, entityplayer); role.addPurchase(i, entityplayer.getDisplayName()); - return soldItem; + // Sync updated data after purchase + // For per-player stocks: sync only to the purchasing player + // For shared stocks: consumeStock() handles sync (market-wide or local) + if (entityplayer instanceof EntityPlayerMP) { + if (role.stock.perPlayer) { + // Per-player: only sync to purchasing player (their own stock/balance) + role.syncToPlayer((EntityPlayerMP) entityplayer); + } + // Shared stock sync is already handled in consumeStock() + } + + return soldItem; } public boolean isSlotEnabled(int slot, EntityPlayer player) { diff --git a/src/main/java/noppes/npcs/containers/ContainerNPCTraderSetup.java b/src/main/java/noppes/npcs/containers/ContainerNPCTraderSetup.java index 8209f22a8..f5d347e16 100644 --- a/src/main/java/noppes/npcs/containers/ContainerNPCTraderSetup.java +++ b/src/main/java/noppes/npcs/containers/ContainerNPCTraderSetup.java @@ -13,15 +13,19 @@ public class ContainerNPCTraderSetup extends Container { public ContainerNPCTraderSetup(EntityNPCInterface npc, EntityPlayer player) { role = (RoleTrader) npc.roleInterface; + // New layout: $[Currency TextField], Item Cost 1, Item Cost 2, =, Output + // Column width: 130px for better spacing for (int i = 0; i < 18; i++) { int x = 7; - x += i % 3 * 94; + x += i % 3 * 130; int y = 15; y += i / 3 * 22; - addSlotToContainer(new Slot(role.inventoryCurrency, i + 18, x, y)); - addSlotToContainer(new Slot(role.inventoryCurrency, i, x + 18, y)); - addSlotToContainer(new Slot(role.inventorySold, i, x + 43, y)); - + // Item Cost 1 (primary currency) + addSlotToContainer(new Slot(role.inventoryCurrency, i, x + 51, y)); + // Item Cost 2 (secondary currency) + addSlotToContainer(new Slot(role.inventoryCurrency, i + 18, x + 69, y)); + // Output (sold item) + addSlotToContainer(new Slot(role.inventorySold, i, x + 105, y)); } for (int i1 = 0; i1 < 3; i1++) { diff --git a/src/main/java/noppes/npcs/controllers/MarketRegistry.java b/src/main/java/noppes/npcs/controllers/MarketRegistry.java new file mode 100644 index 000000000..1110f9e5f --- /dev/null +++ b/src/main/java/noppes/npcs/controllers/MarketRegistry.java @@ -0,0 +1,214 @@ +package noppes.npcs.controllers; + +import foxz.utils.Market; +import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.nbt.NBTTagCompound; +import noppes.npcs.roles.RoleTrader; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Central registry for linked market synchronization. + * Tracks which RoleTrader instances use which markets, + * and which players are viewing each market. + * + * This enables immediate sync across all traders sharing + * the same market when a purchase occurs. + */ +public class MarketRegistry { + + // marketName -> Set of traders using this market (WeakHashMap for auto-cleanup) + private static final Map> marketTraders = new ConcurrentHashMap<>(); + + // marketName -> Set of players viewing this market (across all NPCs) + private static final Map> marketViewers = new ConcurrentHashMap<>(); + + // ==================== Trader Registration ==================== + + /** + * Register a trader as using a linked market. + * Should be called when NPC loads or marketName is set. + */ + public static void registerTrader(String marketName, RoleTrader trader) { + if (marketName == null || marketName.isEmpty() || trader == null) return; + + Set traders = marketTraders.computeIfAbsent(marketName, + k -> Collections.newSetFromMap(new WeakHashMap<>())); + + synchronized (traders) { + traders.add(trader); + } + } + + /** + * Unregister a trader from a linked market. + * Should be called when NPC unloads or marketName changes. + */ + public static void unregisterTrader(String marketName, RoleTrader trader) { + if (marketName == null || marketName.isEmpty() || trader == null) return; + + Set traders = marketTraders.get(marketName); + if (traders != null) { + synchronized (traders) { + traders.remove(trader); + if (traders.isEmpty()) { + marketTraders.remove(marketName); + } + } + } + } + + /** + * Change a trader's market registration. + * Handles unregistering from old market and registering to new. + */ + public static void updateTraderMarket(String oldMarket, String newMarket, RoleTrader trader) { + if (trader == null) return; + + if (oldMarket != null && !oldMarket.isEmpty()) { + unregisterTrader(oldMarket, trader); + } + if (newMarket != null && !newMarket.isEmpty()) { + registerTrader(newMarket, trader); + } + } + + // ==================== Viewer Registration ==================== + + /** + * Register a player as viewing a market (via any NPC). + * Should be called when player opens trader GUI with linked market. + */ + public static void registerViewer(String marketName, EntityPlayerMP player) { + if (marketName == null || marketName.isEmpty() || player == null) return; + + Set viewers = marketViewers.computeIfAbsent(marketName, + k -> Collections.newSetFromMap(new WeakHashMap<>())); + + synchronized (viewers) { + viewers.add(player); + } + } + + /** + * Unregister a player from viewing a market. + * Should be called when player closes trader GUI. + */ + public static void unregisterViewer(String marketName, EntityPlayerMP player) { + if (marketName == null || marketName.isEmpty() || player == null) return; + + Set viewers = marketViewers.get(marketName); + if (viewers != null) { + synchronized (viewers) { + viewers.remove(player); + if (viewers.isEmpty()) { + marketViewers.remove(marketName); + } + } + } + } + + // ==================== Synchronization ==================== + + /** + * Sync stock data to all traders and viewers of a market. + * Called after a purchase to update all linked traders and their viewers. + * + * @param marketName The market to sync + */ + public static void syncMarket(String marketName) { + if (marketName == null || marketName.isEmpty()) return; + + // Get latest data from cache + NBTTagCompound marketData = Market.getMarketCache(marketName); + if (marketData == null) return; + + // Update all traders' internal stock data + Set traders = marketTraders.get(marketName); + if (traders != null) { + NBTTagCompound stockData = marketData.getCompoundTag("Stock"); + + synchronized (traders) { + // Clean up any null references (WeakHashMap cleanup) + traders.removeIf(t -> t == null || t.npc == null); + + for (RoleTrader trader : traders) { + // Update stock data from cache + if (stockData != null && stockData.hasKey("EnableStock")) { + trader.stock.readFromNBT(stockData); + } + } + } + } + + // Sync all viewers (across all NPCs for this market) + syncMarketViewers(marketName); + } + + /** + * Sync stock data to all players viewing a specific market. + * Each viewer gets data relative to their own player (for per-player stock display). + */ + public static void syncMarketViewers(String marketName) { + if (marketName == null || marketName.isEmpty()) return; + + Set viewers = marketViewers.get(marketName); + if (viewers == null || viewers.isEmpty()) return; + + // Find any trader with this market to use for sync (they all have same data now) + Set traders = marketTraders.get(marketName); + if (traders == null || traders.isEmpty()) return; + + RoleTrader syncSource = null; + synchronized (traders) { + for (RoleTrader trader : traders) { + if (trader != null && trader.npc != null) { + syncSource = trader; + break; + } + } + } + + if (syncSource == null) return; + + // Sync to all viewers + synchronized (viewers) { + // Remove disconnected players + viewers.removeIf(p -> p == null || p.playerNetServerHandler == null); + + for (EntityPlayerMP viewer : viewers) { + syncSource.syncToPlayer(viewer); + } + } + } + + // ==================== Utility ==================== + + /** + * Get count of traders registered to a market (for debugging/info). + */ + public static int getTraderCount(String marketName) { + Set traders = marketTraders.get(marketName); + return traders != null ? traders.size() : 0; + } + + /** + * Get count of viewers for a market (for debugging/info). + */ + public static int getViewerCount(String marketName) { + Set viewers = marketViewers.get(marketName); + return viewers != null ? viewers.size() : 0; + } + + /** + * Clear all registrations (for server shutdown/reload). + */ + public static void clear() { + marketTraders.clear(); + marketViewers.clear(); + } +} diff --git a/src/main/java/noppes/npcs/controllers/data/PlayerCurrencyData.java b/src/main/java/noppes/npcs/controllers/data/PlayerCurrencyData.java new file mode 100644 index 000000000..1271594ba --- /dev/null +++ b/src/main/java/noppes/npcs/controllers/data/PlayerCurrencyData.java @@ -0,0 +1,230 @@ +package noppes.npcs.controllers.data; + +import kamkeel.npcs.util.VaultUtil; +import net.minecraft.nbt.NBTTagCompound; +import noppes.npcs.api.handler.IPlayerCurrencyData; +import noppes.npcs.config.ConfigMarket; + +/** + * Stores player currency balance. + * This data is NOT profile slot bound - all profile slots share the same currency pool. + * When Vault API is available and UseVault config is enabled, operations delegate to Vault. + * The built-in CNPC+ currency data is always preserved in NBT, but unused when Vault is active. + */ +public class PlayerCurrencyData implements IPlayerCurrencyData { + private final PlayerData playerData; + + // Built-in CNPC+ currency (always saved, used as fallback) + private long balance; + private long lifetimeEarned; + private long lifetimeSpent; + + public PlayerCurrencyData(PlayerData playerData) { + this.playerData = playerData; + this.balance = ConfigMarket.StartingBalance; + this.lifetimeEarned = 0; + this.lifetimeSpent = 0; + } + + /** + * Check if Vault should be used for currency operations. + * Requires both config enabled AND Vault actually available. + */ + private boolean shouldUseVault() { + return ConfigMarket.UseVault && VaultUtil.isEnabled(); + } + + /** + * Get player name for Vault operations + */ + private String getPlayerName() { + if (playerData != null && playerData.player != null) { + return playerData.player.getCommandSenderName(); + } + return playerData != null ? playerData.playername : null; + } + + public void readFromNBT(NBTTagCompound compound) { + if (compound.hasKey("CurrencyBalance")) { + this.balance = compound.getLong("CurrencyBalance"); + } else { + this.balance = ConfigMarket.StartingBalance; + } + this.lifetimeEarned = compound.getLong("CurrencyLifetimeEarned"); + this.lifetimeSpent = compound.getLong("CurrencyLifetimeSpent"); + } + + public void writeToNBT(NBTTagCompound compound) { + // Always save built-in currency data (preserved even when using Vault) + compound.setLong("CurrencyBalance", this.balance); + compound.setLong("CurrencyLifetimeEarned", this.lifetimeEarned); + compound.setLong("CurrencyLifetimeSpent", this.lifetimeSpent); + } + + /** + * Get the current balance + */ + @Override + public long getBalance() { + if (shouldUseVault()) { + String playerName = getPlayerName(); + if (playerName != null) { + return (long) VaultUtil.getBalance(playerName); + } + } + return balance; + } + + /** + * Set the balance directly (use with caution) + */ + @Override + public void setBalance(long balance) { + if (balance < 0) { + balance = 0; + } + if (balance > ConfigMarket.MaxBalance) { + balance = ConfigMarket.MaxBalance; + } + + if (shouldUseVault()) { + String playerName = getPlayerName(); + if (playerName != null) { + VaultUtil.setBalance(playerName, balance); + return; + } + } + this.balance = balance; + } + + /** + * Add currency to balance (deposit) + * @param amount Amount to add + * @return true if successful, false if would exceed max balance + */ + @Override + public boolean deposit(long amount) { + if (amount <= 0) { + return false; + } + + if (shouldUseVault()) { + String playerName = getPlayerName(); + if (playerName != null) { + boolean success = VaultUtil.addMoney(playerName, amount); + if (success) { + this.lifetimeEarned += amount; + } + return success; + } + } + + // Built-in currency + long newBalance = this.balance + amount; + if (newBalance > ConfigMarket.MaxBalance) { + return false; + } + if (newBalance < this.balance) { + // Overflow protection + return false; + } + this.balance = newBalance; + this.lifetimeEarned += amount; + return true; + } + + /** + * Remove currency from balance (withdraw) + * @param amount Amount to remove + * @return true if successful, false if insufficient funds + */ + @Override + public boolean withdraw(long amount) { + if (amount <= 0) { + return false; + } + + if (shouldUseVault()) { + String playerName = getPlayerName(); + if (playerName != null) { + boolean success = VaultUtil.withdrawMoney(playerName, amount); + if (success) { + this.lifetimeSpent += amount; + } + return success; + } + } + + // Built-in currency + if (this.balance < amount) { + return false; + } + this.balance -= amount; + this.lifetimeSpent += amount; + return true; + } + + /** + * Check if player can afford an amount + */ + @Override + public boolean canAfford(long amount) { + if (shouldUseVault()) { + String playerName = getPlayerName(); + if (playerName != null) { + return VaultUtil.has(playerName, amount); + } + } + return this.balance >= amount; + } + + /** + * Get lifetime earned currency + */ + @Override + public long getLifetimeEarned() { + return lifetimeEarned; + } + + /** + * Get lifetime spent currency + */ + @Override + public long getLifetimeSpent() { + return lifetimeSpent; + } + + /** + * Format the balance for display + */ + @Override + public String formatBalance() { + if (shouldUseVault()) { + return VaultUtil.format(getBalance()); + } + return formatAmount(this.balance); + } + + /** + * Format an amount for display + */ + public static String formatAmount(long amount) { + return String.format("%,d %s", amount, ConfigMarket.CurrencyName); + } + + /** + * Check if Vault is being used for currency operations. + * Returns true only if config is enabled AND Vault is actually available. + */ + @Override + public boolean isUsingVault() { + return shouldUseVault(); + } + + /** + * Check if Vault is configured (regardless of availability) + */ + public boolean isVaultConfigured() { + return ConfigMarket.UseVault; + } +} diff --git a/src/main/java/noppes/npcs/controllers/data/PlayerData.java b/src/main/java/noppes/npcs/controllers/data/PlayerData.java index 599f81ff1..d2a949074 100644 --- a/src/main/java/noppes/npcs/controllers/data/PlayerData.java +++ b/src/main/java/noppes/npcs/controllers/data/PlayerData.java @@ -17,6 +17,7 @@ import noppes.npcs.CustomNpcs; import noppes.npcs.LogWriter; import noppes.npcs.api.entity.ICustomNpc; import noppes.npcs.api.handler.IPlayerBankData; +import noppes.npcs.api.handler.IPlayerCurrencyData; import noppes.npcs.api.handler.IPlayerData; import noppes.npcs.api.handler.IPlayerDialogData; import noppes.npcs.api.handler.IPlayerFactionData; @@ -59,6 +60,9 @@ public class PlayerData implements IExtendedEntityProperties, IPlayerData { public DataSkinOverlays skinOverlays = new DataSkinOverlays(this); public MagicData magicData = new MagicData(); + // Currency data - NOT slot-bound, shared across all profile slots + public PlayerCurrencyData currencyData = new PlayerCurrencyData(this); + public ActionManager actionManager = new ActionManager(); public PlayerDataScript scriptData; @@ -397,6 +401,10 @@ public class PlayerData implements IExtendedEntityProperties, IPlayerData { return mailData; } + public IPlayerCurrencyData getCurrencyData() { + return currencyData; + } + public synchronized void save() { // Don't Save this is a Modification of a Profile's PlayerData Profile profile = ProfileController.Instance.getProfile(UUID.fromString(uuid)); @@ -406,6 +414,9 @@ public class PlayerData implements IExtendedEntityProperties, IPlayerData { } final NBTTagCompound compound = getNBT(); + // Currency data is saved at player level, NOT per profile slot + currencyData.writeToNBT(compound); + final String filename; if (ConfigMain.DatFormat) { filename = uuid + ".dat"; @@ -442,6 +453,10 @@ public class PlayerData implements IExtendedEntityProperties, IPlayerData { if (data.hasNoTags()) { data = getNBT(); } + + // Load currency BEFORE setNBT - currency is player-level, NOT per profile slot + currencyData.readFromNBT(data); + setNBT(data); } diff --git a/src/main/java/noppes/npcs/controllers/data/TraderStock.java b/src/main/java/noppes/npcs/controllers/data/TraderStock.java new file mode 100644 index 000000000..68bc96c53 --- /dev/null +++ b/src/main/java/noppes/npcs/controllers/data/TraderStock.java @@ -0,0 +1,353 @@ +package noppes.npcs.controllers.data; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.nbt.NBTTagList; +import noppes.npcs.constants.EnumStockReset; + +import java.util.HashMap; +import java.util.Map; + +/** + * Manages trader stock system including: + * - Per-slot stock limits + * - Per-player vs per-server stock tracking + * - Automatic stock reset timers + */ +public class TraderStock { + // Stock configuration + public boolean enableStock = false; + public boolean perPlayer = false; // false = per-server, true = per-player + + // Reset configuration (mirrors Quest cooldown system) + public EnumStockReset resetType = EnumStockReset.NONE; + public long customResetTime = 0; // For MCCUSTOM/RLCUSTOM types (in ticks or ms) + + // Stock quantities per slot (max stock, -1 = unlimited) + public int[] maxStock = new int[18]; + + // Current stock (per-server mode) + public int[] currentStock = new int[18]; + public long lastResetTime = 0; + + // Per-player stock tracking + public HashMap playerStock = new HashMap<>(); + + public TraderStock() { + for (int i = 0; i < 18; i++) { + maxStock[i] = -1; // -1 = unlimited + currentStock[i] = -1; + } + } + + /** + * Get available stock for a slot + * @param slot The slot index + * @param playerName The player name (for per-player mode) + * @return Available stock count, or Integer.MAX_VALUE if unlimited + */ + public synchronized int getAvailableStock(int slot, String playerName) { + if (!enableStock || slot < 0 || slot >= 18 || maxStock[slot] < 0) { + return Integer.MAX_VALUE; // Unlimited + } + + if (perPlayer) { + PlayerTraderStock pStock = playerStock.get(playerName); + if (pStock == null) { + return maxStock[slot]; // Not purchased yet + } + return pStock.getStock(slot, maxStock[slot]); + } + + if (currentStock[slot] < 0) { + return maxStock[slot]; + } + return Math.max(0, currentStock[slot]); + } + + /** + * Check if stock is available for purchase + */ + public boolean hasStock(int slot, String playerName, int amount) { + return getAvailableStock(slot, playerName) >= amount; + } + + /** + * Consume stock when purchase is made + * @return true if successful, false if insufficient stock + */ + public synchronized boolean consumeStock(int slot, String playerName, int amount) { + if (!enableStock || slot < 0 || slot >= 18 || maxStock[slot] < 0) { + return true; // Unlimited, always succeeds + } + + if (perPlayer) { + PlayerTraderStock pStock = playerStock.computeIfAbsent( + playerName, k -> new PlayerTraderStock()); + return pStock.consumeStock(slot, maxStock[slot], amount); + } + + if (currentStock[slot] < 0) { + currentStock[slot] = maxStock[slot]; + } + if (currentStock[slot] >= amount) { + currentStock[slot] -= amount; + return true; + } + return false; + } + + /** + * Check if stock should reset based on current time + * @param currentTime Current MC world time (ticks) or real time (ms) depending on reset type + */ + public boolean shouldReset(long currentTime) { + if (resetType == EnumStockReset.NONE) { + return false; + } + + long elapsed = currentTime - lastResetTime; + long resetInterval = getResetInterval(); + + return elapsed >= resetInterval; + } + + /** + * Reset all stock to max values + * @param currentTime Current time to record as reset time + */ + public synchronized void resetStock(long currentTime) { + lastResetTime = currentTime; + + // Reset per-server stock + for (int i = 0; i < 18; i++) { + currentStock[i] = maxStock[i]; + } + + // Clear per-player stock (cleanup per-player data on reset) + playerStock.clear(); + } + + /** + * Get reset interval based on reset type + * @return Interval in ticks (MC time) or milliseconds (real time) + */ + public long getResetInterval() { + switch (resetType) { + case MCDAILY: + return 24000; // 1 MC day in ticks + case MCWEEKLY: + return 168000; // 7 MC days in ticks + case MCCUSTOM: + return customResetTime; // Custom ticks + case RLDAILY: + return 86400000L; // 24 hours in ms + case RLWEEKLY: + return 604800000L; // 7 days in ms + case RLCUSTOM: + return customResetTime; // Custom ms + default: + return Long.MAX_VALUE; + } + } + + /** + * Get time until next reset + * @param currentTime Current time + * @return Time remaining until reset, or -1 if no reset scheduled + */ + public long getTimeUntilReset(long currentTime) { + if (resetType == EnumStockReset.NONE) { + return -1; + } + long elapsed = currentTime - lastResetTime; + long interval = getResetInterval(); + return Math.max(0, interval - elapsed); + } + + /** + * Format time until reset for display + * @param currentTime Current time + * @return Formatted string like "2 hours" or "1 day, 5 hours" + */ + public String getTimeUntilResetFormatted(long currentTime) { + long remaining = getTimeUntilReset(currentTime); + if (remaining < 0) { + return ""; + } + + if (resetType.isRealTime()) { + // Convert ms to readable format + long hours = remaining / 3600000L; + long days = hours / 24; + hours = hours % 24; + + if (days > 0) { + return days + " day" + (days != 1 ? "s" : "") + + (hours > 0 ? ", " + hours + " hour" + (hours != 1 ? "s" : "") : ""); + } else if (hours > 0) { + return hours + " hour" + (hours != 1 ? "s" : ""); + } else { + long minutes = remaining / 60000L; + return minutes + " minute" + (minutes != 1 ? "s" : ""); + } + } else { + // Convert ticks to MC time format + long mcHours = remaining / 1000; // 1000 ticks = 1 MC hour + long mcDays = mcHours / 24; + mcHours = mcHours % 24; + + if (mcDays > 0) { + return mcDays + " MC day" + (mcDays != 1 ? "s" : "") + + (mcHours > 0 ? ", " + mcHours + " MC hour" + (mcHours != 1 ? "s" : "") : ""); + } else { + return mcHours + " MC hour" + (mcHours != 1 ? "s" : ""); + } + } + } + + /** + * Set stock for a specific slot + */ + public void setMaxStock(int slot, int amount) { + if (slot >= 0 && slot < 18) { + maxStock[slot] = amount; + if (currentStock[slot] < 0 || currentStock[slot] > amount) { + currentStock[slot] = amount; + } + } + } + + /** + * Validate and clamp all current stock values to not exceed max stock. + * Should be called when trader GUI opens to ensure consistency. + * @return true if any stock was clamped + */ + public boolean validateStock() { + boolean changed = false; + for (int i = 0; i < 18; i++) { + if (maxStock[i] >= 0) { + // Has a max limit + if (currentStock[i] < 0) { + // Not initialized, set to max + currentStock[i] = maxStock[i]; + changed = true; + } else if (currentStock[i] > maxStock[i]) { + // Exceeds max, clamp down + currentStock[i] = maxStock[i]; + changed = true; + } + } + } + + // Also validate per-player stock + for (PlayerTraderStock pStock : playerStock.values()) { + for (int i = 0; i < 18; i++) { + if (maxStock[i] >= 0) { + int available = pStock.getStock(i, maxStock[i]); + if (available < 0) { + // Over-purchased somehow, reset + pStock.purchasedAmounts[i] = maxStock[i]; + changed = true; + } + } + } + } + return changed; + } + + // ==================== NBT Serialization ==================== + + public NBTTagCompound writeToNBT(NBTTagCompound compound) { + compound.setBoolean("EnableStock", enableStock); + compound.setBoolean("PerPlayer", perPlayer); + compound.setInteger("ResetType", resetType.ordinal()); + compound.setLong("CustomResetTime", customResetTime); + compound.setIntArray("MaxStock", maxStock); + compound.setIntArray("CurrentStock", currentStock); + compound.setLong("LastResetTime", lastResetTime); + + // Save per-player stock + NBTTagList playerList = new NBTTagList(); + for (Map.Entry entry : playerStock.entrySet()) { + NBTTagCompound playerTag = new NBTTagCompound(); + playerTag.setString("Player", entry.getKey()); + entry.getValue().writeToNBT(playerTag); + playerList.appendTag(playerTag); + } + compound.setTag("PlayerStock", playerList); + + return compound; + } + + public void readFromNBT(NBTTagCompound compound) { + enableStock = compound.getBoolean("EnableStock"); + perPlayer = compound.getBoolean("PerPlayer"); + + int resetOrdinal = compound.getInteger("ResetType"); + if (resetOrdinal >= 0 && resetOrdinal < EnumStockReset.values().length) { + resetType = EnumStockReset.values()[resetOrdinal]; + } + + customResetTime = compound.getLong("CustomResetTime"); + + int[] loadedMax = compound.getIntArray("MaxStock"); + int[] loadedCurrent = compound.getIntArray("CurrentStock"); + + if (loadedMax != null && loadedMax.length == 18) { + maxStock = loadedMax; + } + if (loadedCurrent != null && loadedCurrent.length == 18) { + currentStock = loadedCurrent; + } + + lastResetTime = compound.getLong("LastResetTime"); + + // Load per-player stock + playerStock.clear(); + NBTTagList playerList = compound.getTagList("PlayerStock", 10); + for (int i = 0; i < playerList.tagCount(); i++) { + NBTTagCompound playerTag = playerList.getCompoundTagAt(i); + String playerName = playerTag.getString("Player"); + PlayerTraderStock pStock = new PlayerTraderStock(); + pStock.readFromNBT(playerTag); + playerStock.put(playerName, pStock); + } + } + + // ==================== Inner Class for Per-Player Stock ==================== + + public static class PlayerTraderStock { + public int[] purchasedAmounts = new int[18]; + + public PlayerTraderStock() { + for (int i = 0; i < 18; i++) { + purchasedAmounts[i] = 0; + } + } + + public int getStock(int slot, int maxStock) { + if (slot < 0 || slot >= 18) return 0; + return Math.max(0, maxStock - purchasedAmounts[slot]); + } + + public boolean consumeStock(int slot, int maxStock, int amount) { + if (slot < 0 || slot >= 18) return false; + if (getStock(slot, maxStock) >= amount) { + purchasedAmounts[slot] += amount; + return true; + } + return false; + } + + public void writeToNBT(NBTTagCompound compound) { + compound.setIntArray("Purchased", purchasedAmounts); + } + + public void readFromNBT(NBTTagCompound compound) { + int[] loaded = compound.getIntArray("Purchased"); + if (loaded != null && loaded.length == 18) { + purchasedAmounts = loaded; + } + } + } +} diff --git a/src/main/java/noppes/npcs/entity/EntityNPCInterface.java b/src/main/java/noppes/npcs/entity/EntityNPCInterface.java index 702d27cd0..c30f15f57 100644 --- a/src/main/java/noppes/npcs/entity/EntityNPCInterface.java +++ b/src/main/java/noppes/npcs/entity/EntityNPCInterface.java @@ -1303,6 +1303,11 @@ public abstract class EntityNPCInterface extends EntityCreature implements IEnti this.setRevengeTarget(null); this.deathTime = 0; //fleeingTick = 0; + + // Restore hitbox before moving entity to prevent bounding box validation issues + currentAnimation = EnumAnimation.NONE; + updateHitbox(); + if (ais.returnToStart && !hasOwner() && !this.isRemote()) setLocationAndAngles(getStartXPos(), getStartYPos(), getStartZPos(), rotationYaw, rotationPitch); killedtime = 0; @@ -1316,8 +1321,6 @@ public abstract class EntityNPCInterface extends EntityCreature implements IEnti } moveEntityWithHeading(0, 0); distanceWalkedModified = 0; - currentAnimation = EnumAnimation.NONE; - updateHitbox(); updateAI = true; ais.movingPos = 0; if (getOwner() != null) { diff --git a/src/main/java/noppes/npcs/roles/RoleTrader.java b/src/main/java/noppes/npcs/roles/RoleTrader.java index 5275c26c0..107076a44 100644 --- a/src/main/java/noppes/npcs/roles/RoleTrader.java +++ b/src/main/java/noppes/npcs/roles/RoleTrader.java @@ -1,17 +1,25 @@ package noppes.npcs.roles; import foxz.utils.Market; +import kamkeel.npcs.network.packets.data.large.GuiDataPacket; 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.NBTTags; import noppes.npcs.NoppesUtilPlayer; import noppes.npcs.NoppesUtilServer; import noppes.npcs.NpcMiscInventory; import noppes.npcs.constants.EnumGuiType; +import noppes.npcs.controllers.MarketRegistry; +import noppes.npcs.controllers.data.PlayerData; +import noppes.npcs.controllers.data.TraderStock; import noppes.npcs.entity.EntityNPCInterface; import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; @@ -30,6 +38,15 @@ public class RoleTrader extends RoleInterface { public HashMap playerPurchases; public HashMap playerDisableSlot; + // Stock system + public TraderStock stock = new TraderStock(); + + // Currency cost per trade slot (ADDITIVE to item costs, 0 = no currency required) + public long[] currencyCost = new long[18]; + + // Local viewer tracking for non-linked traders (per-NPC sync) + private final Set localViewers = new HashSet<>(); + public RoleTrader(EntityNPCInterface npc) { super(npc); inventoryCurrency = new NpcMiscInventory(36); @@ -59,14 +76,31 @@ public class RoleTrader extends RoleInterface { nbttagcompound.setIntArray("Purchases", purchases); nbttagcompound.setTag("PlayerPurchases", NBTTags.nbtStringIntegerArrayMap(playerPurchases)); } + + // Stock system + nbttagcompound.setTag("Stock", stock.writeToNBT(new NBTTagCompound())); + + // Currency cost per slot (additive to item costs) + NBTTagList currencyList = new NBTTagList(); + for (long cost : currencyCost) { + NBTTagCompound costTag = new NBTTagCompound(); + costTag.setLong("Cost", cost); + currencyList.appendTag(costTag); + } + nbttagcompound.setTag("CurrencyCost", currencyList); + return nbttagcompound; } @Override public void readFromNBT(NBTTagCompound nbttagcompound) { + String oldMarket = marketName; marketName = nbttagcompound.getString("TraderMarket"); readNBT(nbttagcompound); + // Register with MarketRegistry for linked market sync + MarketRegistry.updateTraderMarket(oldMarket, marketName, this); + try { Market.getMarket(this, marketName); } catch (Exception ex) { @@ -94,16 +128,56 @@ public class RoleTrader extends RoleInterface { disableSlot = new int[18]; for (int i = 0; i < disableSlot.length; ++i) disableSlot[i] = 0; } + + // Stock system + if (nbttagcompound.hasKey("Stock")) { + stock.readFromNBT(nbttagcompound.getCompoundTag("Stock")); + } + + // Currency cost per slot (additive to item costs) + if (nbttagcompound.hasKey("CurrencyCost")) { + NBTTagList currencyList = nbttagcompound.getTagList("CurrencyCost", 10); + for (int i = 0; i < currencyList.tagCount() && i < 18; i++) { + currencyCost[i] = currencyList.getCompoundTagAt(i).getLong("Cost"); + } + } } @Override public void interact(EntityPlayer player) { npc.say(player, npc.advanced.getInteractLine()); + + // Load from market FIRST to get latest shared data try { Market.getMarket(this, marketName); } catch (Exception ex) { Logger.getLogger(getClass().getName()).log(Level.SEVERE, null, ex); } + + // THEN check for stock reset (after loading latest market data) + if (stock.enableStock) { + long currentTime = stock.resetType.isRealTime() + ? System.currentTimeMillis() + : player.worldObj.getTotalWorldTime(); + + boolean needsSave = false; + + if (stock.shouldReset(currentTime)) { + stock.resetStock(currentTime); + needsSave = true; + } + + // Validate stock levels (clamp current to max if exceeded) + if (stock.validateStock()) { + needsSave = true; + } + + // Save to market if using shared market and changes were made + if (needsSave && !marketName.isEmpty()) { + Market.save(this, marketName); + } + } + NoppesUtilServer.sendOpenGui(player, EnumGuiType.PlayerTrader, npc); } @@ -138,4 +212,244 @@ public class RoleTrader extends RoleInterface { return map.get(name); } + // ==================== Stock System Methods ==================== + + /** + * Check if there is stock available for a slot + */ + public boolean hasStock(int slot, String playerName, int amount) { + if (!stock.enableStock) { + return true; // Stock system disabled, always available + } + return stock.hasStock(slot, playerName, amount); + } + + /** + * Get available stock for a slot + */ + public int getAvailableStock(int slot, String playerName) { + if (!stock.enableStock) { + return Integer.MAX_VALUE; // Unlimited + } + return stock.getAvailableStock(slot, playerName); + } + + /** + * Consume stock after purchase. + * For shared stock, syncs all viewers (market-wide for linked markets, per-NPC otherwise). + * + * @param slot The slot purchased from + * @param playerName The player making the purchase + * @param amount Amount to consume + * @return true if stock was consumed successfully + */ + public boolean consumeStock(int slot, String playerName, int amount) { + if (!stock.enableStock) { + return true; // Stock system disabled + } + boolean consumed = stock.consumeStock(slot, playerName, amount); + if (consumed) { + // Save to market if linked + if (!marketName.isEmpty()) { + Market.save(this, marketName); + } + + // Sync viewers for shared stock (not per-player) + if (!stock.perPlayer) { + if (!marketName.isEmpty()) { + // Linked market: sync all traders and viewers market-wide + MarketRegistry.syncMarket(marketName); + } else { + // No linked market: sync only this NPC's viewers + syncLocalViewers(); + } + } + } + return consumed; + } + + /** + * Get time until stock reset formatted for display + */ + public String getTimeUntilResetFormatted() { + if (!stock.enableStock || stock.resetType == noppes.npcs.constants.EnumStockReset.NONE) { + return ""; + } + long currentTime = stock.resetType.isRealTime() + ? System.currentTimeMillis() + : (npc != null && npc.worldObj != null ? npc.worldObj.getTotalWorldTime() : 0); + return stock.getTimeUntilResetFormatted(currentTime); + } + + /** + * Get time until stock reset in real-time milliseconds. + * Converts MC ticks to approximate real-time for client countdown display. + * @return Remaining time in milliseconds, or -1 if no reset scheduled + */ + public long getResetTimeRemainingMillis() { + if (!stock.enableStock || stock.resetType == noppes.npcs.constants.EnumStockReset.NONE) { + return -1; + } + + long currentTime; + long remaining; + + if (stock.resetType.isRealTime()) { + // Real time - already in milliseconds + currentTime = System.currentTimeMillis(); + remaining = stock.getTimeUntilReset(currentTime); + } else { + // MC time - convert ticks to real milliseconds (1 tick = 50ms) + currentTime = (npc != null && npc.worldObj != null ? npc.worldObj.getTotalWorldTime() : 0); + long remainingTicks = stock.getTimeUntilReset(currentTime); + remaining = remainingTicks * 50L; + } + + return remaining; + } + + /** + * Reset the stock cooldown timer (for admin use) + */ + public void resetCooldown() { + stock.lastResetTime = 0; + } + + // ==================== Currency Cost Methods ==================== + + /** + * Get the currency cost for a slot (additive to item costs) + */ + public long getCurrencyCost(int slot) { + if (slot < 0 || slot >= 18) return 0; + return currencyCost[slot]; + } + + /** + * Set the currency cost for a slot (additive to item costs) + */ + public void setCurrencyCost(int slot, long cost) { + if (slot >= 0 && slot < 18) { + currencyCost[slot] = Math.max(0, cost); + } + } + + /** + * Check if a slot has a currency cost (greater than 0) + */ + public boolean hasCurrencyCost(int slot) { + return slot >= 0 && slot < 18 && currencyCost[slot] > 0; + } + + // ==================== Viewer Tracking Methods ==================== + + /** + * Register a player as viewing this trader's GUI. + * - For linked markets: uses MarketRegistry (market-wide sync) + * - For normal traders: uses local viewer set (per-NPC sync) + */ + public void registerViewer(EntityPlayerMP player) { + if (player == null) return; + + if (!marketName.isEmpty()) { + // Linked market: use MarketRegistry for market-wide tracking + MarketRegistry.registerViewer(marketName, player); + } else { + // No linked market: use local viewer set + localViewers.add(player); + } + } + + /** + * Unregister a player from viewing this trader's GUI. + * - For linked markets: uses MarketRegistry + * - For normal traders: uses local viewer set + */ + public void unregisterViewer(EntityPlayerMP player) { + if (player == null) return; + + if (!marketName.isEmpty()) { + MarketRegistry.unregisterViewer(marketName, player); + } else { + localViewers.remove(player); + } + } + + /** + * Sync trader data to all players viewing this trader. + * - For linked markets: syncs ALL viewers across ALL NPCs sharing the market + * - For normal traders: syncs only viewers of THIS specific NPC + */ + public void syncAllViewers() { + if (!marketName.isEmpty()) { + // Linked market: sync all viewers across all NPCs with this market + MarketRegistry.syncMarketViewers(marketName); + } else { + // No linked market: sync only local viewers + syncLocalViewers(); + } + } + + /** + * Sync to all players viewing this specific NPC (for non-linked traders). + */ + private void syncLocalViewers() { + // Remove disconnected players + localViewers.removeIf(player -> player.playerNetServerHandler == null); + + for (EntityPlayerMP viewer : localViewers) { + syncToPlayer(viewer); + } + } + + /** + * Called when this trader is unloaded (NPC death, chunk unload, etc.) + * Cleans up MarketRegistry registration. + */ + public void onUnload() { + if (!marketName.isEmpty()) { + MarketRegistry.unregisterTrader(marketName, this); + } + localViewers.clear(); + } + + /** + * Send trader data to a specific player + */ + public void syncToPlayer(EntityPlayerMP player) { + if (player == null || player.playerNetServerHandler == null) { + return; + } + + NBTTagCompound compound = new NBTTagCompound(); + + // Player balance + PlayerData data = PlayerData.get(player); + compound.setLong("Balance", data.currencyData.getBalance()); + + // Stock info + compound.setBoolean("StockEnabled", stock.enableStock); + compound.setString("ResetTime", getTimeUntilResetFormatted()); + + // Available stock per slot (player-specific if perPlayer mode) + String playerName = player.getCommandSenderName(); + int[] stockArr = new int[18]; + for (int i = 0; i < 18; i++) { + stockArr[i] = getAvailableStock(i, playerName); + } + compound.setIntArray("Stock", stockArr); + + // Currency cost per slot + for (int i = 0; i < 18; i++) { + compound.setLong("Cost" + i, getCurrencyCost(i)); + } + + GuiDataPacket.sendGuiData(player, compound); + } + + @Override + public void delete() { + onUnload(); + super.delete(); + } } diff --git a/src/main/java/noppes/npcs/scripted/entity/ScriptPlayer.java b/src/main/java/noppes/npcs/scripted/entity/ScriptPlayer.java index 6206cfbac..85a15041e 100644 --- a/src/main/java/noppes/npcs/scripted/entity/ScriptPlayer.java +++ b/src/main/java/noppes/npcs/scripted/entity/ScriptPlayer.java @@ -895,4 +895,43 @@ public class ScriptPlayer extends ScriptLivingBase } return (IPlayer[]) list.toArray(new IPlayer[list.size()]); } + + // ========================================= + // Currency Methods + // ========================================= + + @Override + public long getCurrencyBalance() { + return getData().currencyData.getBalance(); + } + + @Override + public void setCurrencyBalance(long amount) { + getData().currencyData.setBalance(amount); + } + + @Override + public boolean depositCurrency(long amount) { + return getData().currencyData.deposit(amount); + } + + @Override + public boolean withdrawCurrency(long amount) { + return getData().currencyData.withdraw(amount); + } + + @Override + public boolean canAffordCurrency(long amount) { + return getData().currencyData.canAfford(amount); + } + + @Override + public boolean isUsingVaultCurrency() { + return getData().currencyData.isUsingVault(); + } + + @Override + public String getFormattedCurrencyBalance() { + return getData().currencyData.formatBalance(); + } } diff --git a/src/main/java/noppes/npcs/scripted/roles/ScriptRoleTrader.java b/src/main/java/noppes/npcs/scripted/roles/ScriptRoleTrader.java index 825c2e46b..3574e529e 100644 --- a/src/main/java/noppes/npcs/scripted/roles/ScriptRoleTrader.java +++ b/src/main/java/noppes/npcs/scripted/roles/ScriptRoleTrader.java @@ -42,7 +42,7 @@ public class ScriptRoleTrader extends ScriptRoleInterface implements IRoleTrader public IItemStack getSellOption(int slot) { if (slot >= 18 || slot < 0) return null; if (role.inventorySold.items.get(slot) == null) return null; - return NpcAPI.Instance().getIItemStack(role.inventoryCurrency.items.get(slot)); + return NpcAPI.Instance().getIItemStack(role.inventorySold.items.get(slot)); } public IItemStack[] getCurrency(int slot) { @@ -100,7 +100,7 @@ public class ScriptRoleTrader extends ScriptRoleInterface implements IRoleTrader public boolean isSlotEnabled(int slot) { if (slot >= 18 || slot < 0) return false; - return role.disableSlot[slot] > 0; + return role.disableSlot[slot] <= 0; // enabled when NOT disabled } public boolean isSlotEnabled(int slot, IPlayer player) { @@ -128,6 +128,132 @@ public class ScriptRoleTrader extends ScriptRoleInterface implements IRoleTrader role.getArrayByName(player.getDisplayName(), role.playerDisableSlot)[slot] = 0; } + // ==================== Stock System ==================== + + public boolean isStockEnabled() { + return role.stock.enableStock; + } + + public void setStockEnabled(boolean enabled) { + role.stock.enableStock = enabled; + } + + public boolean isPerPlayerStock() { + return role.stock.perPlayer; + } + + public void setPerPlayerStock(boolean perPlayer) { + role.stock.perPlayer = perPlayer; + } + + public int getStockResetType() { + return role.stock.resetType.ordinal(); + } + + public void setStockResetType(int type) { + noppes.npcs.constants.EnumStockReset[] values = noppes.npcs.constants.EnumStockReset.values(); + if (type >= 0 && type < values.length) { + role.stock.resetType = values[type]; + } + } + + public long getCustomResetTime() { + return role.stock.customResetTime; + } + + public void setCustomResetTime(long time) { + role.stock.customResetTime = Math.max(0, time); + } + + public int getMaxStock(int slot) { + if (slot < 0 || slot >= 18) return -1; + return role.stock.maxStock[slot]; + } + + public void setMaxStock(int slot, int amount) { + if (slot >= 0 && slot < 18) { + role.stock.setMaxStock(slot, amount); + } + } + + public int getAvailableStock(int slot) { + if (slot < 0 || slot >= 18) return 0; + return role.stock.getAvailableStock(slot, ""); + } + + public int getAvailableStock(int slot, IPlayer player) { + if (slot < 0 || slot >= 18) return 0; + return role.stock.getAvailableStock(slot, player.getDisplayName()); + } + + public void resetStock() { + long currentTime = role.stock.resetType.isRealTime() + ? System.currentTimeMillis() + : (npc.worldObj != null ? npc.worldObj.getTotalWorldTime() : 0); + role.stock.resetStock(currentTime); + } + + public void resetCooldown() { + long currentTime = role.stock.resetType.isRealTime() + ? System.currentTimeMillis() + : (npc.worldObj != null ? npc.worldObj.getTotalWorldTime() : 0); + role.stock.lastResetTime = currentTime; + } + + public int getCurrentStock(int slot) { + if (slot < 0 || slot >= 18) return -1; + return role.stock.currentStock[slot]; + } + + public void setCurrentStock(int slot, int amount) { + if (slot >= 0 && slot < 18) { + role.stock.currentStock[slot] = amount; + } + } + + public int getPlayerPurchased(int slot, IPlayer player) { + if (slot < 0 || slot >= 18) return 0; + noppes.npcs.controllers.data.TraderStock.PlayerTraderStock pStock = + role.stock.playerStock.get(player.getDisplayName()); + if (pStock == null) return 0; + return pStock.purchasedAmounts[slot]; + } + + public void setPlayerPurchased(int slot, IPlayer player, int amount) { + if (slot < 0 || slot >= 18) return; + noppes.npcs.controllers.data.TraderStock.PlayerTraderStock pStock = + role.stock.playerStock.computeIfAbsent( + player.getDisplayName(), + k -> new noppes.npcs.controllers.data.TraderStock.PlayerTraderStock() + ); + pStock.purchasedAmounts[slot] = Math.max(0, amount); + } + + public long getLastResetTime() { + return role.stock.lastResetTime; + } + + public long getTimeUntilReset() { + long currentTime = role.stock.resetType.isRealTime() + ? System.currentTimeMillis() + : (npc.worldObj != null ? npc.worldObj.getTotalWorldTime() : 0); + return role.stock.getTimeUntilReset(currentTime); + } + + // ==================== Currency Cost System ==================== + + public long getCurrencyCost(int slot) { + return role.getCurrencyCost(slot); + } + + public void setCurrencyCost(int slot, long cost) { + role.setCurrencyCost(slot, cost); + } + + public boolean hasCurrencyCost(int slot) { + return role.hasCurrencyCost(slot); + } + @Override public int getType() { return RoleType.TRADER; diff --git a/src/main/resources/assets/customnpcs/lang/en_US.lang b/src/main/resources/assets/customnpcs/lang/en_US.lang index 73124d9bf..715ca4884 100644 --- a/src/main/resources/assets/customnpcs/lang/en_US.lang +++ b/src/main/resources/assets/customnpcs/lang/en_US.lang @@ -429,10 +429,68 @@ role.mount.hover=Hover role.mount.ascendspeed=Ascend Speed role.mount.descendspeed=Descend Speed role.mount.walkspeed=Walk Speed: %s +role.auctioneer=Auctioneer +role.auctioneer.tabs=Tab Visibility +role.auctioneer.showBrowse=Show Browse +role.auctioneer.showMyListings=Show My Listings +role.auctioneer.showMyBids=Show My Bids +role.auctioneer.showClaims=Show Claims +role.auctioneer.actions=Player Actions +role.auctioneer.allowCreate=Allow Create +role.auctioneer.allowBid=Allow Bid +role.auctioneer.allowBuyout=Allow Buyout +role.auctioneer.messages=Messages +role.auctioneer.welcome=Welcome Message +role.auctioneer.noAuction=Auction Disabled Message -trader.insufficient=Insufficient Currency -trader.sufficient=Sufficient Currency +trader.unavailable=Unavailable +trader.insufficient.currency=Insufficient Balance +trader.available=Available trader.slotdisabled=Slot Disabled +trader.currencycost=Cost +trader.outofstock=Out of Stock +trader.instock=in stock +trader.restock=Restock +trader.balance=Balance +trader.reopentrader=Reopen Trader + +stock.options=Stock Options +stock.enable=Enable Stock +stock.perplayer=Per-Player +stock.resettype=Reset Type +stock.customtime=Custom Time +stock.defaultmax=Default Max +stock.perslot=Per-Slot Stock +stock.reset=Reset Stock +stock.resetcooldown=Reset Cooldown +stock.perslot.title=Per-Slot Stock Limits +stock.reset.confirm=Reset all stock? +stock.reset.warning=This cannot be undone. +stock.cooldown.confirm=Reset cooldown timer? +stock.cooldown.warning=Stock will reset on next open. + +stock.reset.never=Never +stock.reset.mcdaily=MC Daily +stock.reset.mcweekly=MC Weekly +stock.reset.mccustom=MC Custom +stock.reset.rldaily=Real Daily +stock.reset.rlweekly=Real Weekly +stock.reset.rlcustom=Real Custom + +stock.options.hover=Configure stock limits and reset timers +stock.enable.hover=Enable limited stock for trade slots +stock.perplayer.hover=Track stock separately for each player +stock.resettype.hover=When stock automatically replenishes +stock.customtime.hover=Set a custom reset interval +stock.defaultmax.hover=Default max stock for all slots (-1 = unlimited) +stock.perslot.hover=Set max stock for individual slots +stock.reset.hover=Reset all stock to max values +stock.resetcooldown.hover=Force stock to reset on next trader open + +gui.settings.hover=Configure trading options +gui.ignoreDamage.hover=Ignore item damage when comparing currency +gui.ignoreNBT.hover=Ignore NBT data when comparing currency +gui.recordHistory.hover=Record purchase history for analytics job.name=Job job.none=No Job @@ -1464,14 +1522,12 @@ ability.target.self=Self ability.target.aoe_self=AOE Self ability.target.aoe_target=AOE Target -ability.telegraph.none=None -ability.telegraph.circle=Circle -ability.telegraph.ring=Ring -ability.telegraph.line=Line -ability.telegraph.cone=Cone -ability.telegraph.point=Point +telegraph.circle=Circle +telegraph.ring=Ring +telegraph.line=Line +telegraph.cone=Cone +telegraph.point=Point -ability.tab.general=General ability.tab.timing=Timing ability.tab.effects=Effects ability.tab.telegraph=Telegraph @@ -1484,7 +1540,6 @@ ability.windUpSound=Wind Up Sound ability.activeSound=Active Sound ability.windUpAnim=Wind Up Anim ability.activeAnim=Active Anim -gui.animations=Animations ability.cnpc.slam=Slam ability.cnpc.heavy_hit=Heavy Hit @@ -1503,15 +1558,11 @@ ability.cnpc.hazard=Hazard ability.cnpc.trap=Trap # Ability GUI - Type Tab -ability.tab.type=Type # Common Type Settings -ability.damage=Damage ability.radius=Radius ability.knockback=Knockback ability.knockbackUp=KB Up -ability.speed=Speed -ability.range=Range ability.maxDist=Max Dist ability.minDist=Min Dist ability.distance=Distance @@ -1538,13 +1589,10 @@ ability.hitRadius=Hit Radius # Cutter Settings ability.arcAngle=Arc Angle ability.sweepWaves=Waves -ability.waveInterval=Interval ability.rotSpeed=Rot Speed ability.piercing=Piercing # Orb Settings -ability.orbSpeed=Speed -ability.orbSize=Size ability.homing=Homing ability.explosive=Explosive @@ -1556,7 +1604,6 @@ ability.aoe=AOE ability.maxTargets=Max Targets # Dash Settings -ability.dashMode=Mode ability.dash.aggressive=Aggressive ability.dash.defensive=Defensive ability.mode=Mode @@ -1596,7 +1643,6 @@ ability.counterAnim=Counter Anim ability.healAmount=Amount ability.healPercent=Percent ability.healRadius=Radius -ability.instant=Instant ability.healSelf=Heal Self ability.healAllies=Heal Allies @@ -1607,9 +1653,6 @@ ability.shape=Shape ability.placement=Placement ability.affectsCaster=Hit Caster ability.ignoreInvuln=Ign Invuln -ability.shape.circle=Circle -ability.shape.ring=Ring -ability.shape.cone=Cone ability.place.caster=At Caster ability.place.target=At Target ability.place.followCaster=Follow Caster @@ -1620,7 +1663,6 @@ ability.place.ahead=Ahead ability.triggerRadius=Trig Rad ability.armTime=Arm Time ability.maxTriggers=Max Trig -ability.visible=Visible # Ability Save/Load ability.save.confirm=Save ability preset @@ -1650,11 +1692,8 @@ ability.shockwaveFalloff=Damage/knockback drop off with distance # Ability Config GUI ability.typeId=Type ID -ability.tab.target=Target ability.conditions=Conditions ability.noTypeSettings=No type-specific settings -ability.sounds=Sounds -ability.animations=Animations # Conditions condition.edit=Edit Condition