Merge branch 'dev' into janino-scripts

This commit is contained in:
bigguy345 committed 2026-01-22 19:53:18 +02:00
commit 3f8373d76f
48 files changed
+3333 -2035

No files matched your search

+834
View File
@@ -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<String, Set<RoleTrader>>` - All traders using each market |
| `marketViewers` | `Map<String, Set<EntityPlayerMP>>` - 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<AuctionListing> 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<AuctionClaim> 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
File diff suppressed because it is too large. Load diff
+1 -1
Submodule src/api updated: 866be1fef5...339e0f164e.
-3
View File
@@ -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);
}
@@ -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());
@@ -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,
}
@@ -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);
}
}
@@ -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<String> getScrollData(EntityPlayer player, EnumGuiType gui, EntityNPCInterface npc) {
@@ -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;
@@ -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));
@@ -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;
@@ -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;
@@ -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()));
@@ -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;
@@ -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));
@@ -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));
@@ -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;
@@ -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));
@@ -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));
@@ -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));
@@ -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));
@@ -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));
@@ -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"));
@@ -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
@@ -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);
}
}
}
@@ -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();
}
}
}
@@ -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;
}
}
}
@@ -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);
}
}
}
}
@@ -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();
}
}
}
@@ -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);
}
}
}
@@ -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"));
@@ -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<GuiCustomScroll>(scrolls.values())) {
scroll.drawHover(i, j);
}
for (GuiNpcButton button : buttons.values()) {
button.drawHover(i, j, subGui);
}
}
@Override
@@ -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);
@@ -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();
}
@@ -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();
}
}
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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) {
@@ -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++) {
@@ -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<String, Set<RoleTrader>> marketTraders = new ConcurrentHashMap<>();
// marketName -> Set of players viewing this market (across all NPCs)
private static final Map<String, Set<EntityPlayerMP>> 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<RoleTrader> 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<RoleTrader> 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<EntityPlayerMP> 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<EntityPlayerMP> 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<RoleTrader> 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<EntityPlayerMP> 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<RoleTrader> 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<RoleTrader> 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<EntityPlayerMP> 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();
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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<String, PlayerTraderStock> 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<String, PlayerTraderStock> 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;
}
}
}
}
@@ -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) {
@@ -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<String, int[]> playerPurchases;
public HashMap<String, int[]> 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<EntityPlayerMP> 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();
}
}
@@ -895,4 +895,43 @@ public class ScriptPlayer<T extends EntityPlayerMP> extends ScriptLivingBase<T>
}
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();
}
}
@@ -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;
@@ -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