most final integrations

This commit is contained in:
Sky Johnson 2025-07-31 15:34:04 -05:00
parent 812dd6716a
commit 4129584165
60 changed files with 15788 additions and 6967 deletions

View File

@ -1,171 +0,0 @@
# EQ2Go Conversion Session Summary
## Project Overview
EQ2Go is a Go rewrite of the EverQuest II server emulator from C++ EQ2EMu. This session focused on converting C++ systems to modern Go packages following existing architectural patterns.
## Completed Conversions
### 1. Player System (internal/player)
**Source:** `internal/Player.h` (1277 lines), `internal/Player.cpp` (1000 lines)
**Created:** 15 files including comprehensive player management system
- Thread-safe player state management with embedded entity.Entity
- Character flags (CF_COMBAT_EXPERIENCE_ENABLED through CF2_80000000)
- Complete player lifecycle management with database persistence
- Event handling and statistics tracking
- Integration interfaces for seamless system interaction
### 2. Groups System (internal/groups)
**Source:** `internal/PlayerGroups.h`, `internal/PlayerGroups.cpp`
**Decision:** Separate package due to independent functionality and global state
**Created:** 7 files supporting individual groups (6 members) and raids (24 players)
- Cross-server group coordination
- Thread-safe group management with leader elections
- Raid functionality supporting 4 groups
- Complete group lifecycle with invite/kick/leave mechanics
### 3. Alt Advancement System (internal/alt_advancement)
**Source:** `internal/AltAdvancement.h`, `internal/AltAdvancement.cpp`, `internal/AltAdvancementDB.cpp`
**Created:** 7 files with complete AA progression system
- **10 AA Tabs:** Class, Subclass, Shadow, Heroic, Tradeskill, Prestige, Tradeskill Prestige, Dragon, Dragon Class, Far Seas
- **Master Lists:** MasterAAList with fast lookups by spell/node ID, MasterAANodeList for tree configurations
- **Player Progression:** AAPlayerState with templates, point management, and purchase tracking
- **Database Operations:** Complete persistence with SQL operations for AA definitions, player progress, and templates
- **Thread Safety:** Comprehensive mutex usage with atomic operations
- **Event System:** Purchase/refund events, template changes, point awards
- **Validation:** Prerequisites, level requirements, class restrictions
- **Statistics:** Usage tracking, performance metrics, player progression stats
## Key Technical Patterns Established
### Go Conversion Standards
- **Thread Safety:** sync.RWMutex for read-heavy operations, sync.Mutex for exclusive access
- **Interface Design:** Comprehensive interfaces for system integration and testing
- **Error Handling:** Go idiomatic error returns with detailed context
- **Memory Management:** Go garbage collection replacing manual C++ memory management
- **Concurrency:** Goroutines and channels for background processing
### Architecture Principles
- **Composition over Inheritance:** Go structs embed other structs (Player embeds entity.Entity)
- **Package Organization:** Clear separation of concerns with dedicated packages
- **Database Abstraction:** Interface-based database operations for flexibility
- **Event-Driven Design:** Event handlers for system notifications and integrations
- **Adapter Pattern:** Adapters for seamless integration between systems
### Code Documentation
- **Function Comments:** Clear explanations without redundant naming conventions
- **System Documentation:** Comprehensive README.md files with usage examples
- **TODO Markers:** Areas for future implementation (Lua integration, advanced mechanics)
## File Structure Created
```
internal/
├── player/ # Player management system (15 files)
│ ├── constants.go # Character flags and constants
│ ├── types.go # Player struct and data types
│ ├── player.go # Core Player implementation
│ ├── interfaces.go # Integration interfaces
│ ├── manager.go # Multi-player management
│ └── README.md # Complete documentation
├── groups/ # Group and raid system (7 files)
│ ├── group.go # Individual group management
│ ├── manager.go # Global group coordination
│ ├── service.go # High-level service interface
│ └── README.md # Group system documentation
└── alt_advancement/ # AA progression system (7 files)
├── constants.go # AA tabs, limits, templates
├── types.go # Core AA data structures
├── master_list.go # MasterAAList and MasterAANodeList
├── manager.go # Central AA system management
├── database.go # Database persistence operations
├── interfaces.go # System integration interfaces
└── README.md # Comprehensive AA documentation
```
## System Integration Points
### Database Layer
- SQLite operations with transaction support
- Interface-based design for database flexibility
- Comprehensive error handling and validation
### Event Systems
- Event handlers for system notifications
- Background processing with goroutines
- Statistics collection and performance tracking
### Caching Strategies
- Simple cache implementations for performance
- Cache statistics and management
- Configurable cache sizes and eviction policies
## C++ to Go Migration Highlights
### Data Structure Conversions
- C++ STL containers → Go maps and slices
- C++ pointers → Go interfaces and composition
- C++ manual memory management → Go garbage collection
- C++ templates → Go interfaces and type assertions
### Concurrency Improvements
- C++ mutexes → Go sync.RWMutex for read-heavy operations
- C++ manual threading → Go goroutines and channels
- C++ callback functions → Go interfaces and method sets
### Error Handling Evolution
- C++ exceptions → Go error interface
- C++ return codes → Go multiple return values
- C++ null pointers → Go nil checking and validation
## Performance Considerations
### Efficient Operations
- Hash maps for O(1) lookups (spell ID, node ID)
- Read-write mutexes for concurrent access patterns
- Batch processing for database operations
- Background processing to avoid blocking gameplay
### Memory Optimization
- Copy-on-read for thread safety
- Proper cleanup and resource management
- Configurable cache sizes
- Sparse data structure handling
## Future Implementation Areas
### Identified TODO Items
- **Lua Integration:** Script-controlled behaviors and custom logic
- **Advanced Validation:** Complex prerequisite checking
- **Web Administration:** Management interfaces for AA system
- **Metrics Integration:** External monitoring system integration
- **Packet Handling:** Complete client communication protocols
### Extension Points
- **Custom AA Trees:** Support for server-specific advancement paths
- **Event System:** Integration with achievement and quest systems
- **Performance Optimization:** Advanced caching and database optimization
- **Testing Framework:** Comprehensive test coverage for all systems
## Session Statistics
- **Total Files Created:** 29 files across 3 major systems
- **Lines of Code:** ~6000 lines of Go code generated
- **C++ Files Analyzed:** 7 major files totaling ~3000 lines
- **Systems Converted:** Player management, Group coordination, Alt Advancement
- **Documentation:** 3 comprehensive README.md files with usage examples
## Next Session Recommendations
### Continuation Pattern
Based on the established pattern, the next logical conversions would be:
1. **Guilds System** - `internal/Guilds.cpp`, `internal/Guilds.h`
2. **PvP System** - `internal/PVP.cpp`, `internal/PVP.h`
3. **Mail System** - `internal/Mail.cpp`, `internal/Mail.h`
4. **Auction System** - `internal/Auction.cpp`, `internal/Auction.h`
### Integration Tasks
- Connect converted systems with existing EQ2Go infrastructure
- Implement packet handlers for client communication
- Add comprehensive test coverage
- Performance optimization and profiling
The conversion maintains full compatibility with the original C++ EQ2EMu protocol while providing modern Go concurrency patterns, better error handling, and cleaner architecture for ongoing development.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,180 @@
package database
import (
"os"
"testing"
)
func TestOpen(t *testing.T) {
// Create a temporary database file
tempFile := "test.db"
defer os.Remove(tempFile)
db, err := Open(tempFile)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
if db == nil {
t.Fatal("Database instance is nil")
}
}
func TestExec(t *testing.T) {
tempFile := "test_exec.db"
defer os.Remove(tempFile)
db, err := Open(tempFile)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Test table creation
err = db.Exec(`CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT)`)
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
// Test data insertion
err = db.Exec(`INSERT INTO test_table (name) VALUES (?)`, "test_name")
if err != nil {
t.Fatalf("Failed to insert data: %v", err)
}
}
func TestQueryRow(t *testing.T) {
tempFile := "test_query.db"
defer os.Remove(tempFile)
db, err := Open(tempFile)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Setup test data
err = db.Exec(`CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT, value INTEGER)`)
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
err = db.Exec(`INSERT INTO test_table (name, value) VALUES (?, ?)`, "test", 42)
if err != nil {
t.Fatalf("Failed to insert data: %v", err)
}
// Test query
row, err := db.QueryRow("SELECT name, value FROM test_table WHERE id = ?", 1)
if err != nil {
t.Fatalf("Failed to query row: %v", err)
}
if row == nil {
t.Fatal("Row is nil")
}
defer row.Close()
name := row.Text(0)
value := row.Int(1)
if name != "test" {
t.Errorf("Expected name 'test', got '%s'", name)
}
if value != 42 {
t.Errorf("Expected value 42, got %d", value)
}
}
func TestQuery(t *testing.T) {
tempFile := "test_query_all.db"
defer os.Remove(tempFile)
db, err := Open(tempFile)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Setup test data
err = db.Exec(`CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT)`)
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
names := []string{"test1", "test2", "test3"}
for _, name := range names {
err = db.Exec(`INSERT INTO test_table (name) VALUES (?)`, name)
if err != nil {
t.Fatalf("Failed to insert data: %v", err)
}
}
// Test query with callback
var results []string
err = db.Query("SELECT name FROM test_table ORDER BY id", func(row *Row) error {
results = append(results, row.Text(0))
return nil
})
if err != nil {
t.Fatalf("Failed to query: %v", err)
}
if len(results) != 3 {
t.Errorf("Expected 3 results, got %d", len(results))
}
for i, expected := range names {
if i < len(results) && results[i] != expected {
t.Errorf("Expected result[%d] = '%s', got '%s'", i, expected, results[i])
}
}
}
func TestTransaction(t *testing.T) {
tempFile := "test_transaction.db"
defer os.Remove(tempFile)
db, err := Open(tempFile)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Setup
err = db.Exec(`CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT)`)
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
// Test successful transaction
err = db.Transaction(func(txDB *DB) error {
err := txDB.Exec(`INSERT INTO test_table (name) VALUES (?)`, "tx_test1")
if err != nil {
return err
}
return txDB.Exec(`INSERT INTO test_table (name) VALUES (?)`, "tx_test2")
})
if err != nil {
t.Fatalf("Transaction failed: %v", err)
}
// Verify data was committed
var count int
row, err := db.QueryRow("SELECT COUNT(*) FROM test_table")
if err != nil {
t.Fatalf("Failed to count rows: %v", err)
}
if row != nil {
count = row.Int(0)
row.Close()
}
if count != 2 {
t.Errorf("Expected 2 rows, got %d", count)
}
}

View File

@ -0,0 +1,22 @@
package entity
import (
"testing"
)
func TestPackageBuild(t *testing.T) {
// Basic test to verify the package builds
entity := NewEntity()
if entity == nil {
t.Fatal("NewEntity returned nil")
}
}
func TestEntityStats(t *testing.T) {
entity := NewEntity()
stats := entity.GetInfoStruct()
if stats == nil {
t.Error("Expected InfoStruct to be initialized")
}
}

View File

@ -0,0 +1,264 @@
package factions
import (
"fmt"
"time"
"eq2emu/internal/database"
)
// DatabaseAdapter implements the factions.Database interface using our database wrapper
type DatabaseAdapter struct {
db *database.DB
}
// NewDatabaseAdapter creates a new database adapter for factions
func NewDatabaseAdapter(db *database.DB) *DatabaseAdapter {
return &DatabaseAdapter{db: db}
}
// LoadAllFactions loads all factions from the database
func (da *DatabaseAdapter) LoadAllFactions() ([]*Faction, error) {
// Create factions table if it doesn't exist
if err := da.db.Exec(`
CREATE TABLE IF NOT EXISTS factions (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
type TEXT,
description TEXT,
negative_change INTEGER DEFAULT 0,
positive_change INTEGER DEFAULT 0,
default_value INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
return nil, fmt.Errorf("failed to create factions table: %w", err)
}
var factions []*Faction
err := da.db.Query("SELECT id, name, type, description, negative_change, positive_change, default_value FROM factions", func(row *database.Row) error {
faction := &Faction{
ID: int32(row.Int64(0)),
Name: row.Text(1),
Type: row.Text(2),
Description: row.Text(3),
NegativeChange: int16(row.Int64(4)),
PositiveChange: int16(row.Int64(5)),
DefaultValue: int32(row.Int64(6)),
}
factions = append(factions, faction)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to load factions: %w", err)
}
return factions, nil
}
// SaveFaction saves a faction to the database
func (da *DatabaseAdapter) SaveFaction(faction *Faction) error {
if faction == nil {
return fmt.Errorf("faction is nil")
}
// Use INSERT OR REPLACE to handle both insert and update
err := da.db.Exec(`
INSERT OR REPLACE INTO factions (id, name, type, description, negative_change, positive_change, default_value, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, faction.ID, faction.Name, faction.Type, faction.Description,
faction.NegativeChange, faction.PositiveChange, faction.DefaultValue, time.Now().Unix())
if err != nil {
return fmt.Errorf("failed to save faction %d: %w", faction.ID, err)
}
return nil
}
// DeleteFaction deletes a faction from the database
func (da *DatabaseAdapter) DeleteFaction(factionID int32) error {
err := da.db.Exec("DELETE FROM factions WHERE id = ?", factionID)
if err != nil {
return fmt.Errorf("failed to delete faction %d: %w", factionID, err)
}
return nil
}
// LoadHostileFactionRelations loads all hostile faction relations
func (da *DatabaseAdapter) LoadHostileFactionRelations() ([]*FactionRelation, error) {
// Create faction_relations table if it doesn't exist
if err := da.db.Exec(`
CREATE TABLE IF NOT EXISTS faction_relations (
faction_id INTEGER NOT NULL,
related_faction_id INTEGER NOT NULL,
is_hostile INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (faction_id, related_faction_id),
FOREIGN KEY (faction_id) REFERENCES factions(id),
FOREIGN KEY (related_faction_id) REFERENCES factions(id)
)
`); err != nil {
return nil, fmt.Errorf("failed to create faction_relations table: %w", err)
}
var relations []*FactionRelation
err := da.db.Query("SELECT faction_id, related_faction_id FROM faction_relations WHERE is_hostile = 1",
func(row *database.Row) error {
relation := &FactionRelation{
FactionID: int32(row.Int64(0)),
HostileFactionID: int32(row.Int64(1)),
}
relations = append(relations, relation)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to load hostile faction relations: %w", err)
}
return relations, nil
}
// LoadFriendlyFactionRelations loads all friendly faction relations
func (da *DatabaseAdapter) LoadFriendlyFactionRelations() ([]*FactionRelation, error) {
var relations []*FactionRelation
err := da.db.Query("SELECT faction_id, related_faction_id FROM faction_relations WHERE is_hostile = 0",
func(row *database.Row) error {
relation := &FactionRelation{
FactionID: int32(row.Int64(0)),
FriendlyFactionID: int32(row.Int64(1)),
}
relations = append(relations, relation)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to load friendly faction relations: %w", err)
}
return relations, nil
}
// SaveFactionRelation saves a faction relation to the database
func (da *DatabaseAdapter) SaveFactionRelation(relation *FactionRelation) error {
if relation == nil {
return fmt.Errorf("faction relation is nil")
}
var relatedFactionID int32
var isHostile int
if relation.HostileFactionID != 0 {
relatedFactionID = relation.HostileFactionID
isHostile = 1
} else if relation.FriendlyFactionID != 0 {
relatedFactionID = relation.FriendlyFactionID
isHostile = 0
} else {
return fmt.Errorf("faction relation has no related faction ID")
}
err := da.db.Exec(`
INSERT OR REPLACE INTO faction_relations (faction_id, related_faction_id, is_hostile)
VALUES (?, ?, ?)
`, relation.FactionID, relatedFactionID, isHostile)
if err != nil {
return fmt.Errorf("failed to save faction relation %d -> %d: %w",
relation.FactionID, relatedFactionID, err)
}
return nil
}
// DeleteFactionRelation deletes a faction relation from the database
func (da *DatabaseAdapter) DeleteFactionRelation(factionID, relatedFactionID int32, isHostile bool) error {
hostileFlag := 0
if isHostile {
hostileFlag = 1
}
err := da.db.Exec("DELETE FROM faction_relations WHERE faction_id = ? AND related_faction_id = ? AND is_hostile = ?",
factionID, relatedFactionID, hostileFlag)
if err != nil {
return fmt.Errorf("failed to delete faction relation %d -> %d: %w",
factionID, relatedFactionID, err)
}
return nil
}
// LoadPlayerFactions loads player faction values from the database
func (da *DatabaseAdapter) LoadPlayerFactions(playerID int32) (map[int32]int32, error) {
// Create player_factions table if it doesn't exist
if err := da.db.Exec(`
CREATE TABLE IF NOT EXISTS player_factions (
player_id INTEGER NOT NULL,
faction_id INTEGER NOT NULL,
faction_value INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (player_id, faction_id),
FOREIGN KEY (faction_id) REFERENCES factions(id)
)
`); err != nil {
return nil, fmt.Errorf("failed to create player_factions table: %w", err)
}
factionValues := make(map[int32]int32)
err := da.db.Query("SELECT faction_id, faction_value FROM player_factions WHERE player_id = ?",
func(row *database.Row) error {
factionID := int32(row.Int64(0))
factionValue := int32(row.Int64(1))
factionValues[factionID] = factionValue
return nil
}, playerID)
if err != nil {
return nil, fmt.Errorf("failed to load player factions for player %d: %w", playerID, err)
}
return factionValues, nil
}
// SavePlayerFaction saves a player's faction value to the database
func (da *DatabaseAdapter) SavePlayerFaction(playerID, factionID, factionValue int32) error {
err := da.db.Exec(`
INSERT OR REPLACE INTO player_factions (player_id, faction_id, faction_value, updated_at)
VALUES (?, ?, ?, ?)
`, playerID, factionID, factionValue, time.Now().Unix())
if err != nil {
return fmt.Errorf("failed to save player faction %d/%d: %w", playerID, factionID, err)
}
return nil
}
// SaveAllPlayerFactions saves all faction values for a player
func (da *DatabaseAdapter) SaveAllPlayerFactions(playerID int32, factionValues map[int32]int32) error {
return da.db.Transaction(func(txDB *database.DB) error {
// Clear existing faction values for this player
if err := txDB.Exec("DELETE FROM player_factions WHERE player_id = ?", playerID); err != nil {
return fmt.Errorf("failed to clear player factions: %w", err)
}
// Insert all current faction values
for factionID, factionValue := range factionValues {
err := txDB.Exec(`
INSERT INTO player_factions (player_id, faction_id, faction_value, updated_at)
VALUES (?, ?, ?, ?)
`, playerID, factionID, factionValue, time.Now().Unix())
if err != nil {
return fmt.Errorf("failed to insert player faction %d/%d: %w", playerID, factionID, err)
}
}
return nil
})
}

View File

@ -0,0 +1,246 @@
package factions
import (
"os"
"testing"
"eq2emu/internal/database"
)
func TestNewFaction(t *testing.T) {
faction := NewFaction(1, "Test Faction", "TestType", "A test faction")
if faction == nil {
t.Fatal("NewFaction returned nil")
}
if faction.ID != 1 {
t.Errorf("Expected ID 1, got %d", faction.ID)
}
if faction.Name != "Test Faction" {
t.Errorf("Expected name 'Test Faction', got '%s'", faction.Name)
}
if faction.Type != "TestType" {
t.Errorf("Expected type 'TestType', got '%s'", faction.Type)
}
if faction.Description != "A test faction" {
t.Errorf("Expected description 'A test faction', got '%s'", faction.Description)
}
}
func TestMasterFactionList(t *testing.T) {
mfl := NewMasterFactionList()
if mfl == nil {
t.Fatal("NewMasterFactionList returned nil")
}
// Test adding faction
faction := NewFaction(100, "Test Faction", "Test", "Test faction")
err := mfl.AddFaction(faction)
if err != nil {
t.Fatalf("Failed to add faction: %v", err)
}
// Test getting faction
retrieved := mfl.GetFaction(100)
if retrieved == nil {
t.Error("Failed to retrieve added faction")
} else if retrieved.Name != "Test Faction" {
t.Errorf("Expected name 'Test Faction', got '%s'", retrieved.Name)
}
// Test getting all factions
factions := mfl.GetAllFactions()
if len(factions) == 0 {
t.Error("Expected at least one faction")
}
}
func TestPlayerFaction(t *testing.T) {
pf := NewPlayerFaction(123)
if pf == nil {
t.Fatal("NewPlayerFaction returned nil")
}
// Test setting faction value
pf.SetFactionValue(1, 1000)
value := pf.GetFactionValue(1)
if value != 1000 {
t.Errorf("Expected faction value 1000, got %d", value)
}
// Test faction modification
pf.IncreaseFaction(1, 500)
value = pf.GetFactionValue(1)
if value != 1500 {
t.Errorf("Expected faction value 1500 after increase, got %d", value)
}
pf.DecreaseFaction(1, 200)
value = pf.GetFactionValue(1)
if value != 1300 {
t.Errorf("Expected faction value 1300 after decrease, got %d", value)
}
// Test consideration calculation
consideration := pf.GetFactionConsideration(1)
if consideration < -4 || consideration > 4 {
t.Errorf("Consideration %d is out of valid range [-4, 4]", consideration)
}
}
func TestFactionRelations(t *testing.T) {
mfl := NewMasterFactionList()
// Add test factions
faction1 := NewFaction(1, "Faction 1", "Test", "Test faction 1")
faction2 := NewFaction(2, "Faction 2", "Test", "Test faction 2")
faction3 := NewFaction(3, "Faction 3", "Test", "Test faction 3")
mfl.AddFaction(faction1)
mfl.AddFaction(faction2)
mfl.AddFaction(faction3)
// Test hostile relations
err := mfl.AddHostileFaction(1, 2)
if err != nil {
t.Fatalf("Failed to add hostile faction: %v", err)
}
isHostile := mfl.IsHostile(1, 2)
if !isHostile {
t.Error("Expected faction 2 to be hostile to faction 1")
}
// Test friendly relations
err = mfl.AddFriendlyFaction(1, 3)
if err != nil {
t.Fatalf("Failed to add friendly faction: %v", err)
}
isFriendly := mfl.IsFriendly(1, 3)
if !isFriendly {
t.Error("Expected faction 3 to be friendly to faction 1")
}
// Test removing relations
err = mfl.RemoveHostileFaction(1, 2)
if err != nil {
t.Fatalf("Failed to remove hostile faction: %v", err)
}
isHostile = mfl.IsHostile(1, 2)
if isHostile {
t.Error("Expected faction 2 to no longer be hostile to faction 1")
}
}
func TestFactionDatabaseIntegration(t *testing.T) {
// Create temporary database
tempFile := "test_factions.db"
defer os.Remove(tempFile)
db, err := database.Open(tempFile)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Create database adapter
dbAdapter := NewDatabaseAdapter(db)
// Test saving faction
faction := NewFaction(100, "Test Faction", "Test", "A test faction")
err = dbAdapter.SaveFaction(faction)
if err != nil {
t.Fatalf("Failed to save faction: %v", err)
}
// Test loading factions
factions, err := dbAdapter.LoadAllFactions()
if err != nil {
t.Fatalf("Failed to load factions: %v", err)
}
if len(factions) != 1 {
t.Errorf("Expected 1 faction, got %d", len(factions))
}
if factions[0].Name != "Test Faction" {
t.Errorf("Expected name 'Test Faction', got '%s'", factions[0].Name)
}
// Test faction relations
relation := &FactionRelation{
FactionID: 100,
HostileFactionID: 200,
}
err = dbAdapter.SaveFactionRelation(relation)
if err != nil {
t.Fatalf("Failed to save faction relation: %v", err)
}
hostileRelations, err := dbAdapter.LoadHostileFactionRelations()
if err != nil {
t.Fatalf("Failed to load hostile relations: %v", err)
}
if len(hostileRelations) != 1 {
t.Errorf("Expected 1 hostile relation, got %d", len(hostileRelations))
}
// Test player faction values
playerFactions := map[int32]int32{
100: 1000,
200: -500,
}
err = dbAdapter.SaveAllPlayerFactions(123, playerFactions)
if err != nil {
t.Fatalf("Failed to save player factions: %v", err)
}
loadedFactions, err := dbAdapter.LoadPlayerFactions(123)
if err != nil {
t.Fatalf("Failed to load player factions: %v", err)
}
if len(loadedFactions) != 2 {
t.Errorf("Expected 2 player factions, got %d", len(loadedFactions))
}
if loadedFactions[100] != 1000 {
t.Errorf("Expected faction 100 value 1000, got %d", loadedFactions[100])
}
if loadedFactions[200] != -500 {
t.Errorf("Expected faction 200 value -500, got %d", loadedFactions[200])
}
}
func TestFactionValidation(t *testing.T) {
mfl := NewMasterFactionList()
// Test nil faction
err := mfl.AddFaction(nil)
if err == nil {
t.Error("Expected error when adding nil faction")
}
// Test invalid faction ID
faction := NewFaction(0, "Invalid", "Test", "Invalid faction")
err = mfl.AddFaction(faction)
if err == nil {
t.Error("Expected error when adding faction with ID 0")
}
// Test empty name
faction = NewFaction(1, "", "Test", "Empty name faction")
err = mfl.AddFaction(faction)
if err == nil {
t.Error("Expected error when adding faction with empty name")
}
}

View File

@ -329,15 +329,17 @@ func (pfm *PlayerFactionManager) LoadPlayerFactions(database Database) error {
return fmt.Errorf("database is nil")
}
// TODO: Implement database loading when database system is integrated
// factionData, err := database.LoadPlayerFactions(pfm.player.GetCharacterID())
// if err != nil {
// return fmt.Errorf("failed to load player factions: %w", err)
// }
//
// for factionID, value := range factionData {
// pfm.playerFaction.SetFactionValue(factionID, value)
// }
// Load player faction data from database
if dbAdapter, ok := database.(*DatabaseAdapter); ok {
factionData, err := dbAdapter.LoadPlayerFactions(pfm.player.GetCharacterID())
if err != nil {
return fmt.Errorf("failed to load player factions: %w", err)
}
for factionID, value := range factionData {
pfm.playerFaction.SetFactionValue(factionID, value)
}
}
if pfm.logger != nil {
pfm.logger.LogInfo("Player %d: Loaded faction data from database",
@ -355,12 +357,12 @@ func (pfm *PlayerFactionManager) SavePlayerFactions(database Database) error {
factionValues := pfm.playerFaction.GetFactionValues()
// TODO: Implement database saving when database system is integrated
// for factionID, value := range factionValues {
// if err := database.SavePlayerFaction(pfm.player.GetCharacterID(), factionID, value); err != nil {
// return fmt.Errorf("failed to save faction %d: %w", factionID, err)
// }
// }
// Save player faction data to database
if dbAdapter, ok := database.(*DatabaseAdapter); ok {
if err := dbAdapter.SaveAllPlayerFactions(pfm.player.GetCharacterID(), factionValues); err != nil {
return fmt.Errorf("failed to save player factions: %w", err)
}
}
if pfm.logger != nil {
pfm.logger.LogInfo("Player %d: Saved %d faction values to database",

View File

@ -0,0 +1,492 @@
package items
import (
"database/sql"
"fmt"
"log"
"time"
)
// LoadCharacterItems loads all items for a character from the database
func (idb *ItemDatabase) LoadCharacterItems(charID uint32, masterList *MasterItemList) (*PlayerItemList, *EquipmentItemList, error) {
log.Printf("Loading items for character %d", charID)
inventory := NewPlayerItemList()
equipment := NewEquipmentItemList()
stmt := idb.queries["load_character_items"]
if stmt == nil {
return nil, nil, fmt.Errorf("load_character_items query not prepared")
}
rows, err := stmt.Query(charID)
if err != nil {
return nil, nil, fmt.Errorf("failed to query character items: %v", err)
}
defer rows.Close()
itemCount := 0
for rows.Next() {
characterItem, err := idb.scanCharacterItemFromRow(rows, masterList)
if err != nil {
log.Printf("Error scanning character item from row: %v", err)
continue
}
if characterItem == nil {
continue // Item template not found
}
// Place item in appropriate container based on inv_slot_id
if characterItem.Details.InvSlotID >= 0 && characterItem.Details.InvSlotID < 100 {
// Equipment slots (0-25)
if characterItem.Details.InvSlotID < NumSlots {
equipment.SetItem(int8(characterItem.Details.InvSlotID), characterItem, false)
}
} else {
// Inventory, bank, or special slots
inventory.AddItem(characterItem)
}
itemCount++
}
if err = rows.Err(); err != nil {
return nil, nil, fmt.Errorf("error iterating character item rows: %v", err)
}
log.Printf("Loaded %d items for character %d", itemCount, charID)
return inventory, equipment, nil
}
// scanCharacterItemFromRow scans a character item row and creates an item instance
func (idb *ItemDatabase) scanCharacterItemFromRow(rows *sql.Rows, masterList *MasterItemList) (*Item, error) {
var itemID int32
var uniqueID int64
var invSlotID, slotID int32
var appearanceType int8
var icon, icon2, count, tier int16
var bagID int32
var detailsCount int16
var creator sql.NullString
var adorn0, adorn1, adorn2 int32
var groupID int32
var creatorApp sql.NullString
var randomSeed int32
err := rows.Scan(
&itemID, &uniqueID, &invSlotID, &slotID, &appearanceType,
&icon, &icon2, &count, &tier, &bagID, &detailsCount,
&creator, &adorn0, &adorn1, &adorn2, &groupID,
&creatorApp, &randomSeed,
)
if err != nil {
return nil, fmt.Errorf("failed to scan character item row: %v", err)
}
// Get item template from master list
template := masterList.GetItem(itemID)
if template == nil {
log.Printf("Warning: Item template %d not found for character item", itemID)
return nil, nil
}
// Create item instance from template
item := NewItemFromTemplate(template)
// Update with character-specific data
item.Details.UniqueID = uniqueID
item.Details.InvSlotID = invSlotID
item.Details.SlotID = int16(slotID)
item.Details.AppearanceType = int16(appearanceType)
item.Details.Icon = icon
item.Details.ClassicIcon = icon2
item.Details.Count = count
item.Details.Tier = int8(tier)
item.Details.BagID = bagID
// Set creator if present
if creator.Valid {
item.Creator = creator.String
}
// Set adornment slots
item.Adorn0 = adorn0
item.Adorn1 = adorn1
item.Adorn2 = adorn2
// TODO: Handle group items (heirloom items shared between characters)
// TODO: Handle creator appearance
// TODO: Handle random seed for item variations
return item, nil
}
// SaveCharacterItems saves all items for a character to the database
func (idb *ItemDatabase) SaveCharacterItems(charID uint32, inventory *PlayerItemList, equipment *EquipmentItemList) error {
log.Printf("Saving items for character %d", charID)
// Start transaction
tx, err := idb.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %v", err)
}
defer tx.Rollback()
// Delete existing items for this character
_, err = tx.Exec("DELETE FROM character_items WHERE char_id = ?", charID)
if err != nil {
return fmt.Errorf("failed to delete existing character items: %v", err)
}
// Prepare insert statement
insertStmt, err := tx.Prepare(`
INSERT INTO character_items
(char_id, item_id, unique_id, inv_slot_id, slot_id, appearance_type, icon, icon2,
count, tier, bag_id, details_count, creator, adornment_slot0, adornment_slot1,
adornment_slot2, group_id, creator_app, random_seed, created)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return fmt.Errorf("failed to prepare insert statement: %v", err)
}
defer insertStmt.Close()
itemCount := 0
// Save equipped items
if equipment != nil {
for slotID, item := range equipment.GetAllEquippedItems() {
if item != nil {
if err := idb.saveCharacterItem(insertStmt, charID, item, int32(slotID)); err != nil {
return fmt.Errorf("failed to save equipped item: %v", err)
}
itemCount++
}
}
}
// Save inventory items
if inventory != nil {
allItems := inventory.GetAllItems()
for _, item := range allItems {
if item != nil {
if err := idb.saveCharacterItem(insertStmt, charID, item, item.Details.InvSlotID); err != nil {
return fmt.Errorf("failed to save inventory item: %v", err)
}
itemCount++
}
}
}
// Commit transaction
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %v", err)
}
log.Printf("Saved %d items for character %d", itemCount, charID)
return nil
}
// saveCharacterItem saves a single character item
func (idb *ItemDatabase) saveCharacterItem(stmt *sql.Stmt, charID uint32, item *Item, invSlotID int32) error {
// Handle null creator
var creator sql.NullString
if item.Creator != "" {
creator.String = item.Creator
creator.Valid = true
}
// Handle null creator app
var creatorApp sql.NullString
// TODO: Set creator app if needed
_, err := stmt.Exec(
charID,
item.Details.ItemID,
item.Details.UniqueID,
invSlotID,
item.Details.SlotID,
item.Details.AppearanceType,
item.Details.Icon,
item.Details.ClassicIcon,
item.Details.Count,
item.Details.Tier,
item.Details.BagID,
item.Details.Count, // details_count (same as count for now)
creator,
item.Adorn0,
item.Adorn1,
item.Adorn2,
0, // group_id (TODO: implement heirloom groups)
creatorApp,
0, // random_seed (TODO: implement item variations)
time.Now().Format("2006-01-02 15:04:05"),
)
return err
}
// DeleteCharacterItem deletes a specific item from a character's inventory
func (idb *ItemDatabase) DeleteCharacterItem(charID uint32, uniqueID int64) error {
stmt := idb.queries["delete_character_item"]
if stmt == nil {
return fmt.Errorf("delete_character_item query not prepared")
}
result, err := stmt.Exec(charID, uniqueID)
if err != nil {
return fmt.Errorf("failed to delete character item: %v", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %v", err)
}
if rowsAffected == 0 {
return fmt.Errorf("no item found with unique_id %d for character %d", uniqueID, charID)
}
log.Printf("Deleted item %d for character %d", uniqueID, charID)
return nil
}
// DeleteAllCharacterItems deletes all items for a character
func (idb *ItemDatabase) DeleteAllCharacterItems(charID uint32) error {
stmt := idb.queries["delete_character_items"]
if stmt == nil {
return fmt.Errorf("delete_character_items query not prepared")
}
result, err := stmt.Exec(charID)
if err != nil {
return fmt.Errorf("failed to delete character items: %v", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %v", err)
}
log.Printf("Deleted %d items for character %d", rowsAffected, charID)
return nil
}
// SaveSingleCharacterItem saves a single character item (for updates)
func (idb *ItemDatabase) SaveSingleCharacterItem(charID uint32, item *Item) error {
stmt := idb.queries["save_character_item"]
if stmt == nil {
return fmt.Errorf("save_character_item query not prepared")
}
// Handle null creator
var creator sql.NullString
if item.Creator != "" {
creator.String = item.Creator
creator.Valid = true
}
// Handle null creator app
var creatorApp sql.NullString
_, err := stmt.Exec(
charID,
item.Details.ItemID,
item.Details.UniqueID,
item.Details.InvSlotID,
item.Details.SlotID,
item.Details.AppearanceType,
item.Details.Icon,
item.Details.ClassicIcon,
item.Details.Count,
item.Details.Tier,
item.Details.BagID,
item.Details.Count, // details_count
creator,
item.Adorn0,
item.Adorn1,
item.Adorn2,
0, // group_id
creatorApp,
0, // random_seed
time.Now().Format("2006-01-02 15:04:05"),
)
if err != nil {
return fmt.Errorf("failed to save character item: %v", err)
}
return nil
}
// LoadTemporaryItems loads temporary items that may have expired
func (idb *ItemDatabase) LoadTemporaryItems(charID uint32, masterList *MasterItemList) ([]*Item, error) {
query := `
SELECT ci.item_id, ci.unique_id, ci.inv_slot_id, ci.slot_id, ci.appearance_type,
ci.icon, ci.icon2, ci.count, ci.tier, ci.bag_id, ci.details_count,
ci.creator, ci.adornment_slot0, ci.adornment_slot1, ci.adornment_slot2,
ci.group_id, ci.creator_app, ci.random_seed, ci.created
FROM character_items ci
JOIN items i ON ci.item_id = i.id
WHERE ci.char_id = ? AND (i.generic_info_item_flags & ?) > 0
`
rows, err := idb.db.Query(query, charID, Temporary)
if err != nil {
return nil, fmt.Errorf("failed to query temporary items: %v", err)
}
defer rows.Close()
var tempItems []*Item
for rows.Next() {
item, err := idb.scanCharacterItemFromRow(rows, masterList)
if err != nil {
log.Printf("Error scanning temporary item: %v", err)
continue
}
if item != nil {
tempItems = append(tempItems, item)
}
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating temporary item rows: %v", err)
}
return tempItems, nil
}
// CleanupExpiredItems removes expired temporary items from the database
func (idb *ItemDatabase) CleanupExpiredItems(charID uint32) error {
// This would typically check item expiration times and remove expired items
// For now, this is a placeholder implementation
query := `
DELETE FROM character_items
WHERE char_id = ?
AND item_id IN (
SELECT id FROM items
WHERE (generic_info_item_flags & ?) > 0
AND created < datetime('now', '-1 day')
)
`
result, err := idb.db.Exec(query, charID, Temporary)
if err != nil {
return fmt.Errorf("failed to cleanup expired items: %v", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %v", err)
}
if rowsAffected > 0 {
log.Printf("Cleaned up %d expired items for character %d", rowsAffected, charID)
}
return nil
}
// UpdateItemLocation updates an item's location in the database
func (idb *ItemDatabase) UpdateItemLocation(charID uint32, uniqueID int64, invSlotID int32, slotID int16, bagID int32) error {
query := `
UPDATE character_items
SET inv_slot_id = ?, slot_id = ?, bag_id = ?
WHERE char_id = ? AND unique_id = ?
`
result, err := idb.db.Exec(query, invSlotID, slotID, bagID, charID, uniqueID)
if err != nil {
return fmt.Errorf("failed to update item location: %v", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %v", err)
}
if rowsAffected == 0 {
return fmt.Errorf("no item found with unique_id %d for character %d", uniqueID, charID)
}
return nil
}
// UpdateItemCount updates an item's count in the database
func (idb *ItemDatabase) UpdateItemCount(charID uint32, uniqueID int64, count int16) error {
query := `
UPDATE character_items
SET count = ?, details_count = ?
WHERE char_id = ? AND unique_id = ?
`
result, err := idb.db.Exec(query, count, count, charID, uniqueID)
if err != nil {
return fmt.Errorf("failed to update item count: %v", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %v", err)
}
if rowsAffected == 0 {
return fmt.Errorf("no item found with unique_id %d for character %d", uniqueID, charID)
}
return nil
}
// GetCharacterItemCount returns the number of items a character has
func (idb *ItemDatabase) GetCharacterItemCount(charID uint32) (int32, error) {
query := `SELECT COUNT(*) FROM character_items WHERE char_id = ?`
var count int32
err := idb.db.QueryRow(query, charID).Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to get character item count: %v", err)
}
return count, nil
}
// GetCharacterItemsByBag returns all items in a specific bag for a character
func (idb *ItemDatabase) GetCharacterItemsByBag(charID uint32, bagID int32, masterList *MasterItemList) ([]*Item, error) {
query := `
SELECT item_id, unique_id, inv_slot_id, slot_id, appearance_type, icon, icon2,
count, tier, bag_id, details_count, creator, adornment_slot0, adornment_slot1,
adornment_slot2, group_id, creator_app, random_seed
FROM character_items
WHERE char_id = ? AND bag_id = ?
ORDER BY slot_id
`
rows, err := idb.db.Query(query, charID, bagID)
if err != nil {
return nil, fmt.Errorf("failed to query character items by bag: %v", err)
}
defer rows.Close()
var items []*Item
for rows.Next() {
item, err := idb.scanCharacterItemFromRow(rows, masterList)
if err != nil {
log.Printf("Error scanning character item from row: %v", err)
continue
}
if item != nil {
items = append(items, item)
}
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating character item rows: %v", err)
}
return items, nil
}

View File

@ -34,7 +34,8 @@ const (
EQ2FoodSlot = 22
EQ2DrinkSlot = 23
EQ2TexturesSlot = 24
EQ2HairSlot = 25
EQ2UnknownSlot = 25 // From CoE header - appears to be unused
EQ2HairSlot = 25 // From DoV header
EQ2BeardSlot = 26
EQ2WingsSlot = 27
EQ2NakedChestSlot = 28
@ -178,13 +179,16 @@ const (
ItemTypeHouse = 10
ItemTypeThrown = 11
ItemTypeHouseContainer = 12
ItemTypeAdornment = 13
ItemTypeGenericAdornment = 14
ItemTypeProfile = 16
ItemTypePattern = 17
ItemTypeArmorset = 18
ItemTypeItemcrate = 18
ItemTypeBook = 19
ItemTypeBook = 13 // From header files
ItemTypeAdornment = 14 // From header files
ItemTypePattern = 15 // From header files
ItemTypeArmorset = 16 // From header files
ItemTypeGenericAdornment = 14 // Alternate name
ItemTypeProfile = 16 // From DoV header
ItemTypePatternSet = 17 // From DoV header (alternate name)
ItemTypeItemSet = 18 // From DoV header (alternate name)
ItemTypeItemcrate = 18 // Alternate name
ItemTypeBookOld = 19 // Moved in newer versions
ItemTypeDecoration = 20
ItemTypeDungeonMaker = 21
ItemTypeMarketplace = 22
@ -216,6 +220,7 @@ const (
ItemMenuTypeTest3 = 1310720
ItemMenuTypeTest4 = 2097152
ItemMenuTypeTest5 = 4194304
ItemMenuTypeDrink = 8388608 // From CoE header
ItemMenuTypeTest6 = 8388608
ItemMenuTypeTest7 = 16777216
ItemMenuTypeTest8 = 33554432
@ -405,6 +410,7 @@ const (
ItemStatCrushing = 105
ItemStatDefense = 106
ItemStatDeflection = 107
ItemStatDeflectionChance = 400 // From DoV header
ItemStatDisruption = 108
ItemStatFishing = 109
ItemStatFletching = 110
@ -477,6 +483,7 @@ const (
ItemStatPower = 501
ItemStatConcentration = 502
ItemStatSavagery = 503
ItemStatDissonance = 504 // From ToV header
)
// Advanced stats (600+)

View File

@ -0,0 +1,204 @@
package items
// ToV (Tears of Veeshan) client-specific stat constants
// These constants are used when serializing items for ToV clients
// ToV stat type 6 (blue stats)
const (
TOVItemStatHPRegen = 600
TOVItemStatManaRegen = 601
TOVItemStatHPRegenPPT = 602
TOVItemStatMPRegenPPT = 603
TOVItemStatCombatHPRegenPPT = 604
TOVItemStatCombatMPRegenPPT = 605
TOVItemStatMaxHP = 606
TOVItemStatMaxHPPerc = 607
TOVItemStatMaxHPPercFinal = 608
TOVItemStatSpeed = 609
TOVItemStatSlow = 610
TOVItemStatMountSpeed = 611
TOVItemStatMountAirSpeed = 612
TOVItemStatLeapSpeed = 613
TOVItemStatLeapTime = 614
TOVItemStatGlideEfficiency = 615
TOVItemStatOffensiveSpeed = 616
TOVItemStatAttackSpeed = 617
TOVItemStatMaxMana = 618
TOVItemStatMaxManaPerc = 619
TOVItemStatMaxAttPerc = 620
TOVItemStatBlurVision = 621
TOVItemStatMagicLevelImmunity = 622
TOVItemStatHateGainMod = 623
TOVItemStatCombatExpMod = 624
TOVItemStatTradeskillExpMod = 625
TOVItemStatAchievementExpMod = 626
TOVItemStatSizeMod = 627
TOVItemStatDPS = 628
TOVItemStatStealth = 629
TOVItemStatInvis = 630
TOVItemStatSeeStealth = 631
TOVItemStatSeeInvis = 632
TOVItemStatEffectiveLevelMod = 633
TOVItemStatRiposteChance = 634
TOVItemStatParryChance = 635
TOVItemStatDodgeChance = 636
TOVItemStatAEAutoattackChance = 637
TOVItemStatMultiAttackChance = 638 // DOUBLEATTACKCHANCE
TOVItemStatSpellMultiAttackChance = 639
TOVItemStatFlurry = 640
TOVItemStatMeleeDamageMultiplier = 641
TOVItemStatExtraHarvestChance = 642
TOVItemStatExtraShieldBlockChance = 643
TOVItemStatItemHPRegenPPT = 644
TOVItemStatItemPPRegenPPT = 645
TOVItemStatMeleeCritChance = 646
TOVItemStatCritAvoidance = 647
TOVItemStatBeneficialCritChance = 648
TOVItemStatCritBonus = 649
TOVItemStatPotency = 650 // BASEMODIFIER
TOVItemStatUnconsciousHPMod = 651
TOVItemStatAbilityReuseSpeed = 652 // SPELLTIMEREUSEPCT
TOVItemStatAbilityRecoverySpeed = 653 // SPELLTIMERECOVERYPCT
TOVItemStatAbilityCastingSpeed = 654 // SPELLTIMECASTPCT
TOVItemStatSpellReuseSpeed = 655 // SPELLTIMEREUSESPELLONLY
TOVItemStatMeleeWeaponRange = 656
TOVItemStatRangedWeaponRange = 657
TOVItemStatFallingDamageReduction = 658
TOVItemStatRiposteDamage = 659
TOVItemStatMinimumDeflectionChance = 660
TOVItemStatMovementWeave = 661
TOVItemStatCombatHPRegen = 662
TOVItemStatCombatManaRegen = 663
TOVItemStatContestSpeedBoost = 664
TOVItemStatTrackingAvoidance = 665
TOVItemStatStealthInvisSpeedMod = 666
TOVItemStatLootCoin = 667
TOVItemStatArmorMitigationIncrease = 668
TOVItemStatAmmoConservation = 669
TOVItemStatStrikethrough = 670
TOVItemStatStatusBonus = 671
TOVItemStatAccuracy = 672
TOVItemStatCounterstrike = 673
TOVItemStatShieldBash = 674
TOVItemStatWeaponDamageBonus = 675
TOVItemStatSpellWeaponDamageBonus = 676
TOVItemStatWeaponDamageBonusMeleeOnly = 677
TOVItemStatAdditionalRiposteChance = 678
TOVItemStatPvPToughness = 680
TOVItemStatPvPLethality = 681
TOVItemStatStaminaBonus = 682
TOVItemStatWisdomMitBonus = 683
TOVItemStatHealReceive = 684
TOVItemStatHealReceivePerc = 685
TOVItemStatPvPCriticalMitigation = 686
TOVItemStatBaseAvoidanceBonus = 687
TOVItemStatInCombatSavageryRegen = 688
TOVItemStatOutOfCombatSavageryRegen = 689
TOVItemStatSavageryRegen = 690
TOVItemStatSavageryGainMod = 691
TOVItemStatMaxSavageryLevel = 692
TOVItemStatInCombatDissonanceRegen = 693
TOVItemStatOutOfCombatDissonanceRegen = 694
TOVItemStatDissonanceRegen = 695
TOVItemStatDissonanceGainMod = 696
TOVItemStatAEAutoattackAvoid = 697
)
// ToV stat type 5 (health,power,savagery,dissonance,concentration)
const (
TOVItemStatHealth = 500
TOVItemStatPower = 501
TOVItemStatConcentration = 502
TOVItemStatSavagery = 503
TOVItemStatDissonance = 504
)
// ToV stat type 3 (damage mods)
const (
TOVItemStatDmgSlash = 300
TOVItemStatDmgCrush = 301
TOVItemStatDmgPierce = 302
TOVItemStatDmgHeat = 303
TOVItemStatDmgCold = 304
TOVItemStatDmgMagic = 305
TOVItemStatDmgMental = 306
TOVItemStatDmgDivine = 307
TOVItemStatDmgDisease = 308
TOVItemStatDmgPoison = 309
TOVItemStatDmgDrowning = 310
TOVItemStatDmgFalling = 311
TOVItemStatDmgPain = 312
TOVItemStatDmgMelee = 313
)
// ToV deflection stat
const (
TOVItemStatDeflectionChance = 400
)
// ToV crafting stats (server-only, never sent to client)
const (
TOVItemStatDurabilityMod = 800
TOVItemStatDurabilityAdd = 801
TOVItemStatProgressAdd = 802
TOVItemStatProgressMod = 803
TOVItemStatSuccessMod = 804
TOVItemStatCritSuccessMod = 805
TOVItemStatExDurabilityMod = 806
TOVItemStatExDurabilityAdd = 807
TOVItemStatExProgressMod = 808
TOVItemStatExProgressAdd = 809
TOVItemStatExSuccessMod = 810
TOVItemStatExCritSuccessMod = 811
TOVItemStatExCritFailureMod = 812
TOVItemStatRareHarvestChance = 813
TOVItemStatMaxCrafting = 814
TOVItemStatComponentRefund = 815
TOVItemStatBountifulHarvest = 816
)
// ToV base stats
const (
TOVItemStatStr = 0
TOVItemStatSta = 1
TOVItemStatAgi = 2
TOVItemStatWis = 3
TOVItemStatInt = 4
)
// ToV skill stats
const (
TOVItemStatAdorning = 100
TOVItemStatAggression = 101
TOVItemStatArtificing = 102
TOVItemStatArtistry = 103
TOVItemStatChemistry = 104
TOVItemStatCrushing = 105
TOVItemStatDefense = 106
TOVItemStatDeflection = 107
TOVItemStatDisruption = 108
TOVItemStatFishing = 109
TOVItemStatFletching = 110
TOVItemStatFocus = 111
TOVItemStatForesting = 112
TOVItemStatGathering = 113
TOVItemStatMetalShaping = 114
TOVItemStatMetalworking = 115
TOVItemStatMining = 116
TOVItemStatMinistration = 117
TOVItemStatOrdination = 118
TOVItemStatParry = 119
TOVItemStatPiercing = 120
TOVItemStatRanged = 121
TOVItemStatSafeFall = 122
TOVItemStatScribing = 123
TOVItemStatSculpting = 124
TOVItemStatSlashing = 125
TOVItemStatSubjugation = 126
TOVItemStatSwimming = 127
TOVItemStatTailoring = 128
TOVItemStatTinkering = 129
TOVItemStatTransmuting = 130
TOVItemStatTrapping = 131
TOVItemStatWeaponSkills = 132
)

472
internal/items/database.go Normal file
View File

@ -0,0 +1,472 @@
package items
import (
"database/sql"
"fmt"
"log"
"strconv"
"strings"
"sync/atomic"
"time"
)
// ItemDatabase handles all database operations for items
type ItemDatabase struct {
db *sql.DB
queries map[string]*sql.Stmt
loadedItems map[int32]bool
}
// NewItemDatabase creates a new item database manager
func NewItemDatabase(db *sql.DB) *ItemDatabase {
idb := &ItemDatabase{
db: db,
queries: make(map[string]*sql.Stmt),
loadedItems: make(map[int32]bool),
}
// Prepare commonly used queries
idb.prepareQueries()
return idb
}
// prepareQueries prepares all commonly used SQL queries
func (idb *ItemDatabase) prepareQueries() {
queries := map[string]string{
"load_items": `
SELECT id, soe_id, name, description, icon, icon2, icon_heroic_op, icon_heroic_op2, icon_id,
icon_backdrop, icon_border, icon_tint_red, icon_tint_green, icon_tint_blue, tier,
level, success_sellback, stack_size, generic_info_show_name,
generic_info_item_flags, generic_info_item_flags2, generic_info_creator_flag,
generic_info_condition, generic_info_weight, generic_info_skill_req1,
generic_info_skill_req2, generic_info_skill_min_level, generic_info_item_type,
generic_info_appearance_id, generic_info_appearance_red, generic_info_appearance_green,
generic_info_appearance_blue, generic_info_appearance_highlight_red,
generic_info_appearance_highlight_green, generic_info_appearance_highlight_blue,
generic_info_collectable, generic_info_offers_quest_id, generic_info_part_of_quest_id,
generic_info_max_charges, generic_info_adventure_classes, generic_info_tradeskill_classes,
generic_info_adventure_default_level, generic_info_tradeskill_default_level,
generic_info_usable, generic_info_harvest, generic_info_body_drop,
generic_info_pvp_description, generic_info_merc_only, generic_info_mount_only,
generic_info_set_id, generic_info_collectable_unk, generic_info_transmuted_material,
broker_price, sell_price, max_sell_value, created, script_name, lua_script
FROM items
`,
"load_item_stats": `
SELECT item_id, stat_type, stat_subtype, value, stat_name, level
FROM item_mod_stats
WHERE item_id = ?
`,
"load_item_effects": `
SELECT item_id, effect, percentage, subbulletflag
FROM item_effects
WHERE item_id = ?
`,
"load_item_appearances": `
SELECT item_id, type, red, green, blue, highlight_red, highlight_green, highlight_blue
FROM item_appearances
WHERE item_id = ?
`,
"load_item_level_overrides": `
SELECT item_id, adventure_class, tradeskill_class, level
FROM item_levels_override
WHERE item_id = ?
`,
"load_item_mod_strings": `
SELECT item_id, stat_string
FROM item_mod_strings
WHERE item_id = ?
`,
"load_character_items": `
SELECT item_id, unique_id, inv_slot_id, slot_id, appearance_type, icon, icon2,
count, tier, bag_id, details_count, creator, adornment_slot0, adornment_slot1,
adornment_slot2, group_id, creator_app, random_seed
FROM character_items
WHERE char_id = ?
`,
"save_character_item": `
INSERT OR REPLACE INTO character_items
(char_id, item_id, unique_id, inv_slot_id, slot_id, appearance_type, icon, icon2,
count, tier, bag_id, details_count, creator, adornment_slot0, adornment_slot1,
adornment_slot2, group_id, creator_app, random_seed, created)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
"delete_character_item": `
DELETE FROM character_items WHERE char_id = ? AND unique_id = ?
`,
"delete_character_items": `
DELETE FROM character_items WHERE char_id = ?
`,
}
for name, query := range queries {
if stmt, err := idb.db.Prepare(query); err != nil {
log.Printf("Failed to prepare query %s: %v", name, err)
} else {
idb.queries[name] = stmt
}
}
}
// LoadItems loads all items from the database into the master item list
func (idb *ItemDatabase) LoadItems(masterList *MasterItemList) error {
log.Printf("Loading items from database...")
stmt := idb.queries["load_items"]
if stmt == nil {
return fmt.Errorf("load_items query not prepared")
}
rows, err := stmt.Query()
if err != nil {
return fmt.Errorf("failed to query items: %v", err)
}
defer rows.Close()
itemCount := 0
for rows.Next() {
item, err := idb.scanItemFromRow(rows)
if err != nil {
log.Printf("Error scanning item from row: %v", err)
continue
}
// Load additional item data
if err := idb.loadItemDetails(item); err != nil {
log.Printf("Error loading details for item %d: %v", item.Details.ItemID, err)
continue
}
masterList.AddItem(item)
idb.loadedItems[item.Details.ItemID] = true
itemCount++
}
if err = rows.Err(); err != nil {
return fmt.Errorf("error iterating item rows: %v", err)
}
log.Printf("Loaded %d items from database", itemCount)
return nil
}
// scanItemFromRow scans a database row into an Item struct
func (idb *ItemDatabase) scanItemFromRow(rows *sql.Rows) (*Item, error) {
item := &Item{}
item.ItemStats = make([]*ItemStat, 0)
item.ItemEffects = make([]*ItemEffect, 0)
item.ItemStringStats = make([]*ItemStatString, 0)
item.ItemLevelOverrides = make([]*ItemLevelOverride, 0)
item.SlotData = make([]int8, 0)
var createdStr string
var scriptName, luaScript sql.NullString
err := rows.Scan(
&item.Details.ItemID,
&item.Details.SOEId,
&item.Name,
&item.Description,
&item.Details.Icon,
&item.Details.ClassicIcon,
&item.GenericInfo.AppearanceID, // icon_heroic_op
&item.GenericInfo.AppearanceID, // icon_heroic_op2 (duplicate)
&item.GenericInfo.AppearanceID, // icon_id
&item.GenericInfo.AppearanceID, // icon_backdrop
&item.GenericInfo.AppearanceID, // icon_border
&item.GenericInfo.AppearanceRed, // icon_tint_red
&item.GenericInfo.AppearanceGreen, // icon_tint_green
&item.GenericInfo.AppearanceBlue, // icon_tint_blue
&item.Details.Tier,
&item.Details.RecommendedLevel,
&item.SellPrice, // success_sellback
&item.StackCount,
&item.GenericInfo.ShowName,
&item.GenericInfo.ItemFlags,
&item.GenericInfo.ItemFlags2,
&item.GenericInfo.CreatorFlag,
&item.GenericInfo.Condition,
&item.GenericInfo.Weight,
&item.GenericInfo.SkillReq1,
&item.GenericInfo.SkillReq2,
&item.GenericInfo.SkillMin,
&item.GenericInfo.ItemType,
&item.GenericInfo.AppearanceID,
&item.GenericInfo.AppearanceRed,
&item.GenericInfo.AppearanceGreen,
&item.GenericInfo.AppearanceBlue,
&item.GenericInfo.AppearanceHighlightRed,
&item.GenericInfo.AppearanceHighlightGreen,
&item.GenericInfo.AppearanceHighlightBlue,
&item.GenericInfo.Collectable,
&item.GenericInfo.OffersQuestID,
&item.GenericInfo.PartOfQuestID,
&item.GenericInfo.MaxCharges,
&item.GenericInfo.AdventureClasses,
&item.GenericInfo.TradeskillClasses,
&item.GenericInfo.AdventureDefaultLevel,
&item.GenericInfo.TradeskillDefaultLevel,
&item.GenericInfo.Usable,
&item.GenericInfo.Harvest,
&item.GenericInfo.BodyDrop,
&item.GenericInfo.PvPDescription,
&item.GenericInfo.MercOnly,
&item.GenericInfo.MountOnly,
&item.GenericInfo.SetID,
&item.GenericInfo.CollectableUnk,
&item.GenericInfo.TransmutedMaterial,
&item.BrokerPrice,
&item.SellPrice,
&item.MaxSellValue,
&createdStr,
&scriptName,
&luaScript,
)
if err != nil {
return nil, fmt.Errorf("failed to scan item row: %v", err)
}
// Set lowercase name for searching
item.LowerName = strings.ToLower(item.Name)
// Parse created timestamp
if createdStr != "" {
if created, err := time.Parse("2006-01-02 15:04:05", createdStr); err == nil {
item.Created = created
}
}
// Set script names
if scriptName.Valid {
item.ItemScript = scriptName.String
}
if luaScript.Valid {
item.ItemScript = luaScript.String // Lua script takes precedence
}
// Generate unique ID
item.Details.UniqueID = NextUniqueItemID()
return item, nil
}
// loadItemDetails loads all additional details for an item
func (idb *ItemDatabase) loadItemDetails(item *Item) error {
// Load item stats
if err := idb.loadItemStats(item); err != nil {
return fmt.Errorf("failed to load stats: %v", err)
}
// Load item effects
if err := idb.loadItemEffects(item); err != nil {
return fmt.Errorf("failed to load effects: %v", err)
}
// Load item appearances
if err := idb.loadItemAppearances(item); err != nil {
return fmt.Errorf("failed to load appearances: %v", err)
}
// Load level overrides
if err := idb.loadItemLevelOverrides(item); err != nil {
return fmt.Errorf("failed to load level overrides: %v", err)
}
// Load modifier strings
if err := idb.loadItemModStrings(item); err != nil {
return fmt.Errorf("failed to load mod strings: %v", err)
}
// Load type-specific details
if err := idb.loadItemTypeDetails(item); err != nil {
return fmt.Errorf("failed to load type details: %v", err)
}
return nil
}
// loadItemStats loads item stat modifications
func (idb *ItemDatabase) loadItemStats(item *Item) error {
stmt := idb.queries["load_item_stats"]
if stmt == nil {
return fmt.Errorf("load_item_stats query not prepared")
}
rows, err := stmt.Query(item.Details.ItemID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var stat ItemStat
var itemID int32
var statName sql.NullString
err := rows.Scan(&itemID, &stat.StatType, &stat.StatSubtype, &stat.Value, &statName, &stat.Level)
if err != nil {
return err
}
if statName.Valid {
stat.StatName = statName.String
}
item.ItemStats = append(item.ItemStats, &stat)
}
return rows.Err()
}
// loadItemEffects loads item effects and descriptions
func (idb *ItemDatabase) loadItemEffects(item *Item) error {
stmt := idb.queries["load_item_effects"]
if stmt == nil {
return fmt.Errorf("load_item_effects query not prepared")
}
rows, err := stmt.Query(item.Details.ItemID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var effect ItemEffect
var itemID int32
err := rows.Scan(&itemID, &effect.Effect, &effect.Percentage, &effect.SubBulletFlag)
if err != nil {
return err
}
item.ItemEffects = append(item.ItemEffects, &effect)
}
return rows.Err()
}
// loadItemAppearances loads item appearance data
func (idb *ItemDatabase) loadItemAppearances(item *Item) error {
stmt := idb.queries["load_item_appearances"]
if stmt == nil {
return fmt.Errorf("load_item_appearances query not prepared")
}
rows, err := stmt.Query(item.Details.ItemID)
if err != nil {
return err
}
defer rows.Close()
// Only process the first appearance
if rows.Next() {
var appearance ItemAppearance
var itemID int32
err := rows.Scan(&itemID, &appearance.Type, &appearance.Red, &appearance.Green,
&appearance.Blue, &appearance.HighlightRed, &appearance.HighlightGreen,
&appearance.HighlightBlue)
if err != nil {
return err
}
// Set the appearance data on the item
item.GenericInfo.AppearanceID = appearance.Type
item.GenericInfo.AppearanceRed = appearance.Red
item.GenericInfo.AppearanceGreen = appearance.Green
item.GenericInfo.AppearanceBlue = appearance.Blue
item.GenericInfo.AppearanceHighlightRed = appearance.HighlightRed
item.GenericInfo.AppearanceHighlightGreen = appearance.HighlightGreen
item.GenericInfo.AppearanceHighlightBlue = appearance.HighlightBlue
}
return rows.Err()
}
// loadItemLevelOverrides loads item level overrides for different classes
func (idb *ItemDatabase) loadItemLevelOverrides(item *Item) error {
stmt := idb.queries["load_item_level_overrides"]
if stmt == nil {
return fmt.Errorf("load_item_level_overrides query not prepared")
}
rows, err := stmt.Query(item.Details.ItemID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var override ItemLevelOverride
var itemID int32
err := rows.Scan(&itemID, &override.AdventureClass, &override.TradeskillClass, &override.Level)
if err != nil {
return err
}
item.ItemLevelOverrides = append(item.ItemLevelOverrides, &override)
}
return rows.Err()
}
// loadItemModStrings loads item modifier strings
func (idb *ItemDatabase) loadItemModStrings(item *Item) error {
stmt := idb.queries["load_item_mod_strings"]
if stmt == nil {
return fmt.Errorf("load_item_mod_strings query not prepared")
}
rows, err := stmt.Query(item.Details.ItemID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var statString ItemStatString
var itemID int32
err := rows.Scan(&itemID, &statString.StatString)
if err != nil {
return err
}
item.ItemStringStats = append(item.ItemStringStats, &statString)
}
return rows.Err()
}
// nextUniqueIDCounter is the global counter for unique item IDs
var nextUniqueIDCounter int64 = 1
// NextUniqueItemID generates a unique ID for items (thread-safe)
func NextUniqueItemID() int64 {
return atomic.AddInt64(&nextUniqueIDCounter, 1)
}
// Helper functions for database value parsing (kept for future use)
// Close closes all prepared statements and the database connection
func (idb *ItemDatabase) Close() error {
for name, stmt := range idb.queries {
if err := stmt.Close(); err != nil {
log.Printf("Error closing statement %s: %v", name, err)
}
}
return nil
}

View File

@ -315,15 +315,23 @@ func (eil *EquipmentItemList) GetSlotByItem(item *Item) int8 {
// CalculateEquipmentBonuses calculates stat bonuses from all equipped items
func (eil *EquipmentItemList) CalculateEquipmentBonuses() *ItemStatsValues {
return eil.CalculateEquipmentBonusesWithEntity(nil)
}
// CalculateEquipmentBonusesWithEntity calculates stat bonuses from all equipped items with entity modifiers
func (eil *EquipmentItemList) CalculateEquipmentBonusesWithEntity(entity Entity) *ItemStatsValues {
eil.mutex.RLock()
defer eil.mutex.RUnlock()
totalBonuses := &ItemStatsValues{}
// We need access to the master item list to calculate bonuses
// This would typically be injected or passed as a parameter
// For now, we'll just accumulate basic stats from the items
for _, item := range eil.items {
if item != nil {
// TODO: Implement item bonus calculation
// This should be handled by the master item list
// TODO: Implement item bonus calculation with master item list
// This should call mil.CalculateItemBonusesFromItem(item, entity)
itemBonuses := &ItemStatsValues{} // placeholder
if itemBonuses != nil {
// Add item bonuses to total

View File

@ -190,6 +190,22 @@ type Player interface {
GetAlignment() int8
}
// Entity represents an entity (player or NPC) that can have items
type Entity interface {
GetID() uint32
GetName() string
GetLevel() int16
GetRace() int8
GetGender() int8
GetAlignment() int8
IsPlayer() bool
IsNPC() bool
// GetStatValueByName gets a stat value by name for item calculations
GetStatValueByName(statName string) float64
// GetSkillValueByName gets a skill value by name for item calculations
GetSkillValueByName(skillName string) int32
}
// CraftingRequirement represents a crafting requirement
type CraftingRequirement struct {
ItemID int32 `json:"item_id"`

View File

@ -0,0 +1,501 @@
package items
import (
"database/sql"
"testing"
_ "zombiezen.com/go/sqlite"
)
// setupTestDB creates a test database with minimal schema
func setupTestDB(t *testing.T) *sql.DB {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
// Create minimal test schema
schema := `
CREATE TABLE items (
id INTEGER PRIMARY KEY,
soe_id INTEGER DEFAULT 0,
name TEXT NOT NULL,
description TEXT DEFAULT '',
icon INTEGER DEFAULT 0,
icon2 INTEGER DEFAULT 0,
icon_heroic_op INTEGER DEFAULT 0,
icon_heroic_op2 INTEGER DEFAULT 0,
icon_id INTEGER DEFAULT 0,
icon_backdrop INTEGER DEFAULT 0,
icon_border INTEGER DEFAULT 0,
icon_tint_red INTEGER DEFAULT 0,
icon_tint_green INTEGER DEFAULT 0,
icon_tint_blue INTEGER DEFAULT 0,
tier INTEGER DEFAULT 1,
level INTEGER DEFAULT 1,
success_sellback INTEGER DEFAULT 0,
stack_size INTEGER DEFAULT 1,
generic_info_show_name INTEGER DEFAULT 1,
generic_info_item_flags INTEGER DEFAULT 0,
generic_info_item_flags2 INTEGER DEFAULT 0,
generic_info_creator_flag INTEGER DEFAULT 0,
generic_info_condition INTEGER DEFAULT 100,
generic_info_weight INTEGER DEFAULT 10,
generic_info_skill_req1 INTEGER DEFAULT 0,
generic_info_skill_req2 INTEGER DEFAULT 0,
generic_info_skill_min_level INTEGER DEFAULT 0,
generic_info_item_type INTEGER DEFAULT 0,
generic_info_appearance_id INTEGER DEFAULT 0,
generic_info_appearance_red INTEGER DEFAULT 0,
generic_info_appearance_green INTEGER DEFAULT 0,
generic_info_appearance_blue INTEGER DEFAULT 0,
generic_info_appearance_highlight_red INTEGER DEFAULT 0,
generic_info_appearance_highlight_green INTEGER DEFAULT 0,
generic_info_appearance_highlight_blue INTEGER DEFAULT 0,
generic_info_collectable INTEGER DEFAULT 0,
generic_info_offers_quest_id INTEGER DEFAULT 0,
generic_info_part_of_quest_id INTEGER DEFAULT 0,
generic_info_max_charges INTEGER DEFAULT 0,
generic_info_adventure_classes INTEGER DEFAULT 0,
generic_info_tradeskill_classes INTEGER DEFAULT 0,
generic_info_adventure_default_level INTEGER DEFAULT 1,
generic_info_tradeskill_default_level INTEGER DEFAULT 1,
generic_info_usable INTEGER DEFAULT 0,
generic_info_harvest INTEGER DEFAULT 0,
generic_info_body_drop INTEGER DEFAULT 0,
generic_info_pvp_description INTEGER DEFAULT 0,
generic_info_merc_only INTEGER DEFAULT 0,
generic_info_mount_only INTEGER DEFAULT 0,
generic_info_set_id INTEGER DEFAULT 0,
generic_info_collectable_unk INTEGER DEFAULT 0,
generic_info_transmuted_material INTEGER DEFAULT 0,
broker_price INTEGER DEFAULT 0,
sell_price INTEGER DEFAULT 0,
max_sell_value INTEGER DEFAULT 0,
created TEXT DEFAULT CURRENT_TIMESTAMP,
script_name TEXT DEFAULT '',
lua_script TEXT DEFAULT ''
);
CREATE TABLE item_mod_stats (
item_id INTEGER,
stat_type INTEGER,
stat_subtype INTEGER DEFAULT 0,
value REAL,
stat_name TEXT DEFAULT '',
level INTEGER DEFAULT 0
);
CREATE TABLE item_effects (
item_id INTEGER,
effect TEXT,
percentage INTEGER DEFAULT 0,
subbulletflag INTEGER DEFAULT 0
);
CREATE TABLE item_appearances (
item_id INTEGER,
type INTEGER,
red INTEGER DEFAULT 0,
green INTEGER DEFAULT 0,
blue INTEGER DEFAULT 0,
highlight_red INTEGER DEFAULT 0,
highlight_green INTEGER DEFAULT 0,
highlight_blue INTEGER DEFAULT 0
);
CREATE TABLE item_levels_override (
item_id INTEGER,
adventure_class INTEGER,
tradeskill_class INTEGER,
level INTEGER
);
CREATE TABLE item_mod_strings (
item_id INTEGER,
stat_string TEXT
);
CREATE TABLE character_items (
char_id INTEGER,
item_id INTEGER,
unique_id INTEGER PRIMARY KEY,
inv_slot_id INTEGER,
slot_id INTEGER,
appearance_type INTEGER DEFAULT 0,
icon INTEGER DEFAULT 0,
icon2 INTEGER DEFAULT 0,
count INTEGER DEFAULT 1,
tier INTEGER DEFAULT 1,
bag_id INTEGER DEFAULT 0,
details_count INTEGER DEFAULT 1,
creator TEXT DEFAULT '',
adornment_slot0 INTEGER DEFAULT 0,
adornment_slot1 INTEGER DEFAULT 0,
adornment_slot2 INTEGER DEFAULT 0,
group_id INTEGER DEFAULT 0,
creator_app TEXT DEFAULT '',
random_seed INTEGER DEFAULT 0,
created TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE item_details_weapon (
item_id INTEGER PRIMARY KEY,
wield_type INTEGER DEFAULT 2,
damage_low1 INTEGER DEFAULT 1,
damage_high1 INTEGER DEFAULT 2,
damage_low2 INTEGER DEFAULT 0,
damage_high2 INTEGER DEFAULT 0,
damage_low3 INTEGER DEFAULT 0,
damage_high3 INTEGER DEFAULT 0,
delay_hundredths INTEGER DEFAULT 300,
rating REAL DEFAULT 1.0
);
CREATE TABLE item_details_armor (
item_id INTEGER PRIMARY KEY,
mitigation_low INTEGER DEFAULT 1,
mitigation_high INTEGER DEFAULT 2
);
CREATE TABLE item_details_bag (
item_id INTEGER PRIMARY KEY,
num_slots INTEGER DEFAULT 6,
weight_reduction INTEGER DEFAULT 0
);
`
if _, err := db.Exec(schema); err != nil {
t.Fatalf("Failed to create test schema: %v", err)
}
return db
}
// insertTestItem inserts a test item into the database
func insertTestItem(t *testing.T, db *sql.DB, itemID int32, name string, itemType int8) {
query := `
INSERT INTO items (id, name, generic_info_item_type)
VALUES (?, ?, ?)
`
_, err := db.Exec(query, itemID, name, itemType)
if err != nil {
t.Fatalf("Failed to insert test item: %v", err)
}
}
func TestNewItemDatabase(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
idb := NewItemDatabase(db)
if idb == nil {
t.Fatal("Expected non-nil ItemDatabase")
}
if idb.db != db {
t.Error("Expected database connection to be set")
}
if len(idb.queries) == 0 {
t.Error("Expected queries to be prepared")
}
if len(idb.loadedItems) != 0 {
t.Error("Expected loadedItems to be empty initially")
}
}
func TestLoadItems(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Insert test items
insertTestItem(t, db, 1, "Test Sword", ItemTypeWeapon)
insertTestItem(t, db, 2, "Test Armor", ItemTypeArmor)
insertTestItem(t, db, 3, "Test Bag", ItemTypeBag)
// Add weapon details for sword
_, err := db.Exec(`
INSERT INTO item_details_weapon (item_id, damage_low1, damage_high1, delay_hundredths)
VALUES (1, 10, 15, 250)
`)
if err != nil {
t.Fatalf("Failed to insert weapon details: %v", err)
}
// Add armor details
_, err = db.Exec(`
INSERT INTO item_details_armor (item_id, mitigation_low, mitigation_high)
VALUES (2, 5, 8)
`)
if err != nil {
t.Fatalf("Failed to insert armor details: %v", err)
}
// Add bag details
_, err = db.Exec(`
INSERT INTO item_details_bag (item_id, num_slots, weight_reduction)
VALUES (3, 6, 10)
`)
if err != nil {
t.Fatalf("Failed to insert bag details: %v", err)
}
idb := NewItemDatabase(db)
masterList := NewMasterItemList()
err = idb.LoadItems(masterList)
if err != nil {
t.Fatalf("Failed to load items: %v", err)
}
if masterList.GetItemCount() != 3 {
t.Errorf("Expected 3 items, got %d", masterList.GetItemCount())
}
// Test specific items
sword := masterList.GetItem(1)
if sword == nil {
t.Fatal("Expected to find sword item")
}
if sword.Name != "Test Sword" {
t.Errorf("Expected sword name 'Test Sword', got '%s'", sword.Name)
}
if sword.WeaponInfo == nil {
t.Error("Expected weapon info to be loaded")
} else {
if sword.WeaponInfo.DamageLow1 != 10 {
t.Errorf("Expected damage low 10, got %d", sword.WeaponInfo.DamageLow1)
}
}
armor := masterList.GetItem(2)
if armor == nil {
t.Fatal("Expected to find armor item")
}
if armor.ArmorInfo == nil {
t.Error("Expected armor info to be loaded")
}
bag := masterList.GetItem(3)
if bag == nil {
t.Fatal("Expected to find bag item")
}
if bag.BagInfo == nil {
t.Error("Expected bag info to be loaded")
}
}
func TestLoadItemStats(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Insert test item
insertTestItem(t, db, 1, "Test Item", ItemTypeNormal)
// Add item stats
_, err := db.Exec(`
INSERT INTO item_mod_stats (item_id, stat_type, stat_subtype, value, stat_name)
VALUES (1, 0, 0, 10.0, 'Strength')
`)
if err != nil {
t.Fatalf("Failed to insert item stats: %v", err)
}
idb := NewItemDatabase(db)
masterList := NewMasterItemList()
err = idb.LoadItems(masterList)
if err != nil {
t.Fatalf("Failed to load items: %v", err)
}
item := masterList.GetItem(1)
if item == nil {
t.Fatal("Expected to find item")
}
if len(item.ItemStats) != 1 {
t.Errorf("Expected 1 item stat, got %d", len(item.ItemStats))
}
if item.ItemStats[0].StatName != "Strength" {
t.Errorf("Expected stat name 'Strength', got '%s'", item.ItemStats[0].StatName)
}
if item.ItemStats[0].Value != 10.0 {
t.Errorf("Expected stat value 10.0, got %f", item.ItemStats[0].Value)
}
}
func TestSaveAndLoadCharacterItems(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Insert test item template
insertTestItem(t, db, 1, "Test Item", ItemTypeNormal)
idb := NewItemDatabase(db)
masterList := NewMasterItemList()
// Load item templates
err := idb.LoadItems(masterList)
if err != nil {
t.Fatalf("Failed to load items: %v", err)
}
// Create test character items
inventory := NewPlayerItemList()
equipment := NewEquipmentItemList()
// Create an item instance
template := masterList.GetItem(1)
if template == nil {
t.Fatal("Expected to find item template")
}
item := NewItemFromTemplate(template)
item.Details.InvSlotID = 1000 // Inventory slot
item.Details.Count = 5
inventory.AddItem(item)
// Save character items
charID := uint32(123)
err = idb.SaveCharacterItems(charID, inventory, equipment)
if err != nil {
t.Fatalf("Failed to save character items: %v", err)
}
// Load character items
loadedInventory, loadedEquipment, err := idb.LoadCharacterItems(charID, masterList)
if err != nil {
t.Fatalf("Failed to load character items: %v", err)
}
if loadedInventory.GetNumberOfItems() != 1 {
t.Errorf("Expected 1 inventory item, got %d", loadedInventory.GetNumberOfItems())
}
if loadedEquipment.GetNumberOfItems() != 0 {
t.Errorf("Expected 0 equipped items, got %d", loadedEquipment.GetNumberOfItems())
}
// Verify item properties
allItems := loadedInventory.GetAllItems()
if len(allItems) != 1 {
t.Fatalf("Expected 1 item in all items, got %d", len(allItems))
}
loadedItem := allItems[int32(item.Details.UniqueID)]
if loadedItem == nil {
t.Fatal("Expected to find loaded item")
}
if loadedItem.Details.Count != 5 {
t.Errorf("Expected item count 5, got %d", loadedItem.Details.Count)
}
if loadedItem.Name != "Test Item" {
t.Errorf("Expected item name 'Test Item', got '%s'", loadedItem.Name)
}
}
func TestDeleteCharacterItem(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Insert test character item directly
charID := uint32(123)
uniqueID := int64(456)
_, err := db.Exec(`
INSERT INTO character_items (char_id, item_id, unique_id, inv_slot_id, slot_id, count)
VALUES (?, 1, ?, 1000, 0, 1)
`, charID, uniqueID)
if err != nil {
t.Fatalf("Failed to insert test character item: %v", err)
}
idb := NewItemDatabase(db)
// Delete the item
err = idb.DeleteCharacterItem(charID, uniqueID)
if err != nil {
t.Fatalf("Failed to delete character item: %v", err)
}
// Verify item was deleted
var count int
err = db.QueryRow("SELECT COUNT(*) FROM character_items WHERE char_id = ? AND unique_id = ?", charID, uniqueID).Scan(&count)
if err != nil {
t.Fatalf("Failed to query character items: %v", err)
}
if count != 0 {
t.Errorf("Expected 0 items after deletion, got %d", count)
}
}
func TestGetCharacterItemCount(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
charID := uint32(123)
idb := NewItemDatabase(db)
// Initially should be 0
count, err := idb.GetCharacterItemCount(charID)
if err != nil {
t.Fatalf("Failed to get character item count: %v", err)
}
if count != 0 {
t.Errorf("Expected 0 items initially, got %d", count)
}
// Insert test items
for i := 0; i < 3; i++ {
_, err := db.Exec(`
INSERT INTO character_items (char_id, item_id, unique_id, inv_slot_id, slot_id, count)
VALUES (?, 1, ?, 1000, 0, 1)
`, charID, i+1)
if err != nil {
t.Fatalf("Failed to insert test character item: %v", err)
}
}
// Should now be 3
count, err = idb.GetCharacterItemCount(charID)
if err != nil {
t.Fatalf("Failed to get character item count: %v", err)
}
if count != 3 {
t.Errorf("Expected 3 items, got %d", count)
}
}
func TestNextUniqueItemID(t *testing.T) {
id1 := NextUniqueItemID()
id2 := NextUniqueItemID()
if id1 >= id2 {
t.Errorf("Expected unique IDs to be increasing, got %d and %d", id1, id2)
}
if id1 == id2 {
t.Error("Expected unique IDs to be different")
}
}
func TestItemDatabaseClose(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
idb := NewItemDatabase(db)
// Should not error when closing
err := idb.Close()
if err != nil {
t.Errorf("Expected no error when closing, got: %v", err)
}
}

View File

@ -0,0 +1,549 @@
package items
import (
"database/sql"
"fmt"
"log"
)
// loadItemTypeDetails loads type-specific details for an item based on its type
func (idb *ItemDatabase) loadItemTypeDetails(item *Item) error {
switch item.GenericInfo.ItemType {
case ItemTypeWeapon:
return idb.loadWeaponDetails(item)
case ItemTypeRanged:
return idb.loadRangedWeaponDetails(item)
case ItemTypeArmor:
return idb.loadArmorDetails(item)
case ItemTypeShield:
return idb.loadShieldDetails(item)
case ItemTypeBag:
return idb.loadBagDetails(item)
case ItemTypeSkill:
return idb.loadSkillDetails(item)
case ItemTypeRecipe:
return idb.loadRecipeBookDetails(item)
case ItemTypeFood:
return idb.loadFoodDetails(item)
case ItemTypeBauble:
return idb.loadBaubleDetails(item)
case ItemTypeHouse:
return idb.loadHouseItemDetails(item)
case ItemTypeThrown:
return idb.loadThrownWeaponDetails(item)
case ItemTypeHouseContainer:
return idb.loadHouseContainerDetails(item)
case ItemTypeBook:
return idb.loadBookDetails(item)
case ItemTypeAdornment:
return idb.loadAdornmentDetails(item)
}
// No specific type details needed for this item type
return nil
}
// loadWeaponDetails loads weapon-specific information
func (idb *ItemDatabase) loadWeaponDetails(item *Item) error {
query := `
SELECT wield_type, damage_low1, damage_high1, damage_low2, damage_high2,
damage_low3, damage_high3, delay_hundredths, rating
FROM item_details_weapon
WHERE item_id = ?
`
row := idb.db.QueryRow(query, item.Details.ItemID)
weapon := &WeaponInfo{}
err := row.Scan(
&weapon.WieldType,
&weapon.DamageLow1,
&weapon.DamageHigh1,
&weapon.DamageLow2,
&weapon.DamageHigh2,
&weapon.DamageLow3,
&weapon.DamageHigh3,
&weapon.Delay,
&weapon.Rating,
)
if err != nil {
if err == sql.ErrNoRows {
return nil // No weapon details found
}
return fmt.Errorf("failed to load weapon details: %v", err)
}
item.WeaponInfo = weapon
return nil
}
// loadRangedWeaponDetails loads ranged weapon information
func (idb *ItemDatabase) loadRangedWeaponDetails(item *Item) error {
// First load weapon info
if err := idb.loadWeaponDetails(item); err != nil {
return err
}
query := `
SELECT range_low, range_high
FROM item_details_range
WHERE item_id = ?
`
row := idb.db.QueryRow(query, item.Details.ItemID)
ranged := &RangedInfo{
WeaponInfo: *item.WeaponInfo, // Copy weapon info
}
err := row.Scan(&ranged.RangeLow, &ranged.RangeHigh)
if err != nil {
if err == sql.ErrNoRows {
return nil // No ranged details found
}
return fmt.Errorf("failed to load ranged weapon details: %v", err)
}
item.RangedInfo = ranged
item.WeaponInfo = nil // Clear weapon info since we have ranged info
return nil
}
// loadArmorDetails loads armor mitigation information
func (idb *ItemDatabase) loadArmorDetails(item *Item) error {
query := `
SELECT mitigation_low, mitigation_high
FROM item_details_armor
WHERE item_id = ?
`
row := idb.db.QueryRow(query, item.Details.ItemID)
armor := &ArmorInfo{}
err := row.Scan(&armor.MitigationLow, &armor.MitigationHigh)
if err != nil {
if err == sql.ErrNoRows {
return nil // No armor details found
}
return fmt.Errorf("failed to load armor details: %v", err)
}
item.ArmorInfo = armor
return nil
}
// loadShieldDetails loads shield information
func (idb *ItemDatabase) loadShieldDetails(item *Item) error {
// Load armor details first
if err := idb.loadArmorDetails(item); err != nil {
return err
}
if item.ArmorInfo != nil {
shield := &ShieldInfo{
ArmorInfo: *item.ArmorInfo,
}
item.ArmorInfo = nil // Clear armor info
// Note: In Go we don't have ShieldInfo, just use ArmorInfo
item.ArmorInfo = &shield.ArmorInfo
}
return nil
}
// loadBagDetails loads bag information
func (idb *ItemDatabase) loadBagDetails(item *Item) error {
query := `
SELECT num_slots, weight_reduction
FROM item_details_bag
WHERE item_id = ?
`
row := idb.db.QueryRow(query, item.Details.ItemID)
bag := &BagInfo{}
err := row.Scan(&bag.NumSlots, &bag.WeightReduction)
if err != nil {
if err == sql.ErrNoRows {
return nil // No bag details found
}
return fmt.Errorf("failed to load bag details: %v", err)
}
item.BagInfo = bag
return nil
}
// loadSkillDetails loads skill book information
func (idb *ItemDatabase) loadSkillDetails(item *Item) error {
query := `
SELECT spell_id, spell_tier
FROM item_details_skill
WHERE item_id = ?
`
row := idb.db.QueryRow(query, item.Details.ItemID)
skill := &SkillInfo{}
err := row.Scan(&skill.SpellID, &skill.SpellTier)
if err != nil {
if err == sql.ErrNoRows {
return nil // No skill details found
}
return fmt.Errorf("failed to load skill details: %v", err)
}
item.SkillInfo = skill
item.SpellID = skill.SpellID
item.SpellTier = int8(skill.SpellTier)
return nil
}
// loadRecipeBookDetails loads recipe book information
func (idb *ItemDatabase) loadRecipeBookDetails(item *Item) error {
query := `
SELECT recipe_id, uses
FROM item_details_recipe_book
WHERE item_id = ?
`
row := idb.db.QueryRow(query, item.Details.ItemID)
recipe := &RecipeBookInfo{}
var recipeID int32
err := row.Scan(&recipeID, &recipe.Uses)
if err != nil {
if err == sql.ErrNoRows {
return nil // No recipe book details found
}
return fmt.Errorf("failed to load recipe book details: %v", err)
}
recipe.RecipeID = recipeID
recipe.Recipes = []uint32{uint32(recipeID)} // Add the single recipe
item.RecipeBookInfo = recipe
return nil
}
// loadFoodDetails loads food/drink information
func (idb *ItemDatabase) loadFoodDetails(item *Item) error {
query := `
SELECT type, level, duration, satiation
FROM item_details_food
WHERE item_id = ?
`
row := idb.db.QueryRow(query, item.Details.ItemID)
food := &FoodInfo{}
err := row.Scan(&food.Type, &food.Level, &food.Duration, &food.Satiation)
if err != nil {
if err == sql.ErrNoRows {
return nil // No food details found
}
return fmt.Errorf("failed to load food details: %v", err)
}
item.FoodInfo = food
return nil
}
// loadBaubleDetails loads bauble information
func (idb *ItemDatabase) loadBaubleDetails(item *Item) error {
query := `
SELECT cast, recovery, duration, recast, display_slot_optional,
display_cast_time, display_bauble_type, effect_radius,
max_aoe_targets, display_until_cancelled
FROM item_details_bauble
WHERE item_id = ?
`
row := idb.db.QueryRow(query, item.Details.ItemID)
bauble := &BaubleInfo{}
err := row.Scan(
&bauble.Cast,
&bauble.Recovery,
&bauble.Duration,
&bauble.Recast,
&bauble.DisplaySlotOptional,
&bauble.DisplayCastTime,
&bauble.DisplayBaubleType,
&bauble.EffectRadius,
&bauble.MaxAOETargets,
&bauble.DisplayUntilCancelled,
)
if err != nil {
if err == sql.ErrNoRows {
return nil // No bauble details found
}
return fmt.Errorf("failed to load bauble details: %v", err)
}
item.BaubleInfo = bauble
return nil
}
// loadHouseItemDetails loads house item information
func (idb *ItemDatabase) loadHouseItemDetails(item *Item) error {
query := `
SELECT status_rent_reduction, coin_rent_reduction, house_only, house_location
FROM item_details_house
WHERE item_id = ?
`
row := idb.db.QueryRow(query, item.Details.ItemID)
house := &HouseItemInfo{}
err := row.Scan(
&house.StatusRentReduction,
&house.CoinRentReduction,
&house.HouseOnly,
&house.HouseLocation,
)
if err != nil {
if err == sql.ErrNoRows {
return nil // No house item details found
}
return fmt.Errorf("failed to load house item details: %v", err)
}
item.HouseItemInfo = house
return nil
}
// loadThrownWeaponDetails loads thrown weapon information
func (idb *ItemDatabase) loadThrownWeaponDetails(item *Item) error {
query := `
SELECT range_val, damage_modifier, hit_bonus, damage_type
FROM item_details_thrown
WHERE item_id = ?
`
row := idb.db.QueryRow(query, item.Details.ItemID)
thrown := &ThrownInfo{}
err := row.Scan(
&thrown.Range,
&thrown.DamageModifier,
&thrown.HitBonus,
&thrown.DamageType,
)
if err != nil {
if err == sql.ErrNoRows {
return nil // No thrown weapon details found
}
return fmt.Errorf("failed to load thrown weapon details: %v", err)
}
item.ThrownInfo = thrown
return nil
}
// loadHouseContainerDetails loads house container information
func (idb *ItemDatabase) loadHouseContainerDetails(item *Item) error {
query := `
SELECT allowed_types, num_slots, broker_commission, fence_commission
FROM item_details_house_container
WHERE item_id = ?
`
row := idb.db.QueryRow(query, item.Details.ItemID)
container := &HouseContainerInfo{}
err := row.Scan(
&container.AllowedTypes,
&container.NumSlots,
&container.BrokerCommission,
&container.FenceCommission,
)
if err != nil {
if err == sql.ErrNoRows {
return nil // No house container details found
}
return fmt.Errorf("failed to load house container details: %v", err)
}
item.HouseContainerInfo = container
return nil
}
// loadBookDetails loads book information
func (idb *ItemDatabase) loadBookDetails(item *Item) error {
query := `
SELECT language, author, title
FROM item_details_book
WHERE item_id = ?
`
row := idb.db.QueryRow(query, item.Details.ItemID)
book := &BookInfo{}
err := row.Scan(&book.Language, &book.Author, &book.Title)
if err != nil {
if err == sql.ErrNoRows {
return nil // No book details found
}
return fmt.Errorf("failed to load book details: %v", err)
}
item.BookInfo = book
item.BookLanguage = book.Language
// Load book pages
if err := idb.loadBookPages(item); err != nil {
log.Printf("Error loading book pages for item %d: %v", item.Details.ItemID, err)
}
return nil
}
// loadBookPages loads book page content
func (idb *ItemDatabase) loadBookPages(item *Item) error {
query := `
SELECT page, page_text, page_text_valign, page_text_halign
FROM item_details_book_pages
WHERE item_id = ?
ORDER BY page
`
rows, err := idb.db.Query(query, item.Details.ItemID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var page BookPage
err := rows.Scan(&page.Page, &page.PageText, &page.VAlign, &page.HAlign)
if err != nil {
return err
}
item.BookPages = append(item.BookPages, &page)
}
return rows.Err()
}
// loadAdornmentDetails loads adornment information
func (idb *ItemDatabase) loadAdornmentDetails(item *Item) error {
query := `
SELECT duration, item_types, slot_type
FROM item_details_adornments
WHERE item_id = ?
`
row := idb.db.QueryRow(query, item.Details.ItemID)
adornment := &AdornmentInfo{}
err := row.Scan(&adornment.Duration, &adornment.ItemTypes, &adornment.SlotType)
if err != nil {
if err == sql.ErrNoRows {
return nil // No adornment details found
}
return fmt.Errorf("failed to load adornment details: %v", err)
}
item.AdornmentInfo = adornment
return nil
}
// LoadItemSets loads item set information
func (idb *ItemDatabase) LoadItemSets(masterList *MasterItemList) error {
query := `
SELECT item_id, item_crc, item_icon, item_stack_size, item_list_color
FROM reward_crate_items
ORDER BY item_id
`
rows, err := idb.db.Query(query)
if err != nil {
return fmt.Errorf("failed to query item sets: %v", err)
}
defer rows.Close()
itemSets := make(map[int32][]*ItemSet)
for rows.Next() {
var itemSet ItemSet
err := rows.Scan(
&itemSet.ItemID,
&itemSet.ItemCRC,
&itemSet.ItemIcon,
&itemSet.ItemStackSize,
&itemSet.ItemListColor,
)
if err != nil {
log.Printf("Error scanning item set row: %v", err)
continue
}
// Add to item sets map
itemSets[itemSet.ItemID] = append(itemSets[itemSet.ItemID], &itemSet)
}
if err = rows.Err(); err != nil {
return fmt.Errorf("error iterating item set rows: %v", err)
}
// Associate item sets with items
for itemID, sets := range itemSets {
item := masterList.GetItem(itemID)
if item != nil {
item.ItemSets = sets
}
}
log.Printf("Loaded item sets for %d items", len(itemSets))
return nil
}
// LoadItemClassifications loads item classifications
func (idb *ItemDatabase) LoadItemClassifications(masterList *MasterItemList) error {
query := `
SELECT item_id, classification_id, classification_name
FROM item_classifications
ORDER BY item_id
`
rows, err := idb.db.Query(query)
if err != nil {
return fmt.Errorf("failed to query item classifications: %v", err)
}
defer rows.Close()
classifications := make(map[int32][]*Classifications)
for rows.Next() {
var itemID int32
var classification Classifications
err := rows.Scan(&itemID, &classification.ClassificationID, &classification.ClassificationName)
if err != nil {
log.Printf("Error scanning classification row: %v", err)
continue
}
classifications[itemID] = append(classifications[itemID], &classification)
}
if err = rows.Err(); err != nil {
return fmt.Errorf("error iterating classification rows: %v", err)
}
// Associate classifications with items
for itemID, classifs := range classifications {
item := masterList.GetItem(itemID)
if item != nil {
item.Classifications = classifs
}
}
log.Printf("Loaded classifications for %d items", len(classifications))
return nil
}

View File

@ -0,0 +1,357 @@
# EverQuest II Loot System
This package implements a comprehensive loot generation and management system for the EverQuest II server emulator, converted from the original C++ implementation.
## Overview
The loot system handles:
- **Loot Generation**: Probability-based item and coin generation from configurable loot tables
- **Treasure Chests**: Physical chest spawns with tier-appropriate appearances
- **Global Loot**: Level, race, and zone-based loot assignments
- **Loot Distribution**: Group loot methods and player rights management
- **Database Integration**: Persistent loot table and assignment storage
- **Client Communication**: Version-specific packet building for loot windows
## Architecture
### Core Components
#### LootDatabase (`database.go`)
- Manages all database operations for loot tables, drops, and assignments
- Caches loot data in memory for performance
- Supports real-time updates and reloading
#### LootManager (`manager.go`)
- Central coordinator for loot generation and chest management
- Implements probability-based loot algorithms
- Handles treasure chest lifecycle and cleanup
#### ChestService (`chest.go`)
- Manages treasure chest interactions (view, loot, disarm, lockpick)
- Validates player permissions and positioning
- Integrates with player and zone services
#### LootPacketBuilder (`packets.go`)
- Builds client-specific packets for loot communication
- Supports multiple client versions (v1 through v60114+)
- Handles loot window updates and interaction responses
#### LootSystem (`integration.go`)
- High-level integration layer combining all components
- Provides simplified API for common operations
- Manages system lifecycle and configuration
### Data Structures
#### LootTable
```go
type LootTable struct {
ID int32 // Unique table identifier
Name string // Descriptive name
MinCoin int32 // Minimum coin drop
MaxCoin int32 // Maximum coin drop
MaxLootItems int16 // Maximum items per loot
LootDropProbability float32 // Chance for loot to drop (0-100%)
CoinProbability float32 // Chance for coins to drop (0-100%)
Drops []*LootDrop // Individual item drops
}
```
#### LootDrop
```go
type LootDrop struct {
LootTableID int32 // Parent table ID
ItemID int32 // Item to drop
ItemCharges int16 // Item stack size/charges
EquipItem bool // Auto-equip item
Probability float32 // Drop chance (0-100%)
NoDropQuestCompletedID int32 // Required quest completion
}
```
#### TreasureChest
```go
type TreasureChest struct {
ID int32 // Unique chest ID
SpawnID int32 // Source spawn ID
ZoneID int32 // Zone location
X, Y, Z float32 // Position coordinates
Heading float32 // Orientation
AppearanceID int32 // Visual appearance
LootResult *LootResult // Contained items and coins
Created time.Time // Creation timestamp
LootRights []uint32 // Players with access rights
IsDisarmable bool // Can be disarmed
IsLocked bool // Requires lockpicking
}
```
## Database Schema
### Core Tables
#### loottable
```sql
CREATE TABLE loottable (
id INTEGER PRIMARY KEY,
name TEXT,
mincoin INTEGER DEFAULT 0,
maxcoin INTEGER DEFAULT 0,
maxlootitems INTEGER DEFAULT 6,
lootdrop_probability REAL DEFAULT 100.0,
coin_probability REAL DEFAULT 50.0
);
```
#### lootdrop
```sql
CREATE TABLE lootdrop (
loot_table_id INTEGER,
item_id INTEGER,
item_charges INTEGER DEFAULT 1,
equip_item INTEGER DEFAULT 0,
probability REAL DEFAULT 100.0,
no_drop_quest_completed_id INTEGER DEFAULT 0
);
```
#### spawn_loot
```sql
CREATE TABLE spawn_loot (
spawn_id INTEGER,
loottable_id INTEGER
);
```
#### loot_global
```sql
CREATE TABLE loot_global (
type TEXT, -- 'level', 'race', or 'zone'
loot_table INTEGER, -- Target loot table ID
value1 INTEGER, -- Min level, race ID, or zone ID
value2 INTEGER, -- Max level (for level type)
value3 INTEGER, -- Loot tier
value4 INTEGER -- Reserved
);
```
## Usage Examples
### Basic Setup
```go
// Create loot system
config := &LootSystemConfig{
DatabaseConnection: db,
ItemMasterList: itemMasterList,
PlayerService: playerService,
ZoneService: zoneService,
ClientService: clientService,
ItemPacketBuilder: itemPacketBuilder,
StartCleanupTimer: true,
}
lootSystem, err := NewLootSystem(config)
if err != nil {
log.Fatal(err)
}
```
### Generate Loot and Create Chest
```go
// Create loot context
context := &LootContext{
PlayerLevel: 25,
PlayerRace: 1,
ZoneID: 100,
KillerID: playerID,
GroupMembers: []uint32{playerID},
CompletedQuests: playerQuests,
LootMethod: GroupLootMethodFreeForAll,
}
// Generate loot and create chest
chest, err := lootSystem.GenerateAndCreateChest(
spawnID, zoneID, x, y, z, heading, context)
if err != nil {
log.Printf("Failed to create loot chest: %v", err)
}
```
### Handle Player Loot Interaction
```go
// Player opens chest
err := lootSystem.ShowChestToPlayer(chestID, playerID)
// Player loots specific item
err = lootSystem.HandlePlayerLootInteraction(
chestID, playerID, ChestInteractionLoot, itemUniqueID)
// Player loots everything
err = lootSystem.HandlePlayerLootInteraction(
chestID, playerID, ChestInteractionLootAll, 0)
```
### Create Loot Tables
```go
// Create a simple loot table
items := []QuickLootItem{
{ItemID: 1001, Charges: 1, Probability: 100.0, AutoEquip: false},
{ItemID: 1002, Charges: 5, Probability: 50.0, AutoEquip: false},
{ItemID: 1003, Charges: 1, Probability: 25.0, AutoEquip: true},
}
err := lootSystem.CreateQuickLootTable(
tableID, "Orc Warrior Loot", items, 10, 50, 3)
// Assign to spawns
spawnIDs := []int32{2001, 2002, 2003}
err = lootSystem.AssignLootToSpawns(tableID, spawnIDs)
```
## Chest Appearances
Chest appearance is automatically selected based on the highest tier item:
| Tier Range | Appearance | Chest Type |
|------------|------------|------------|
| 1-2 (Common-Uncommon) | 4034 | Small Chest |
| 3-4 (Treasured-Rare) | 5864 | Treasure Chest |
| 5-6 (Legendary-Fabled) | 5865 | Ornate Chest |
| 7+ (Mythical+) | 4015 | Exquisite Chest |
## Loot Generation Algorithm
1. **Table Selection**: Get loot tables assigned to spawn + applicable global tables
2. **Drop Probability**: Roll against `lootdrop_probability` to determine if loot drops
3. **Coin Generation**: If `coin_probability` succeeds, generate random coins between min/max
4. **Item Processing**: For each loot drop:
- Check quest requirements
- Roll against item probability
- Generate item instance with specified charges
- Stop when `maxlootitems` reached
5. **Chest Creation**: If items qualify (tier >= Common), create treasure chest
## Global Loot System
Global loot provides automatic loot assignment based on:
### Level-Based Loot
```go
err := lootSystem.CreateGlobalLevelLoot(10, 20, tableID, LootTierCommon)
```
### Race-Based Loot
```sql
INSERT INTO loot_global (type, loot_table, value1, value2)
VALUES ('race', 100, 1, 0); -- Human racial loot
```
### Zone-Based Loot
```sql
INSERT INTO loot_global (type, loot_table, value1, value2)
VALUES ('zone', 200, 150, 0); -- Zone 150 specific loot
```
## Group Loot Methods
The system supports various loot distribution methods:
- **Free For All**: Anyone can loot anything
- **Round Robin**: Items distributed in turn order
- **Master Looter**: Designated player distributes loot
- **Need/Greed**: Players roll need or greed for items
- **Lotto**: Random distribution for high-tier items
## Statistics and Monitoring
```go
// Get comprehensive statistics
stats, err := lootSystem.GetSystemStatistics()
// Check loot generation stats
genStats := lootSystem.Manager.GetStatistics()
fmt.Printf("Total loots: %d, Average items per loot: %.2f",
genStats.TotalLoots, genStats.AverageItemsPerLoot)
```
## Validation and Debugging
```go
// Validate all items in loot tables exist
errors := lootSystem.ValidateItemsInLootTables()
for _, err := range errors {
fmt.Printf("Validation error: %s\n", err.Description)
}
// Preview potential loot without generating
preview, err := lootSystem.GetLootPreview(spawnID, context)
fmt.Printf("Possible items: %d, Coin range: %d-%d",
len(preview.PossibleItems), preview.MinCoins, preview.MaxCoins)
```
## Performance Considerations
- **Memory Caching**: All loot tables are cached in memory for fast access
- **Prepared Statements**: Database queries use prepared statements for efficiency
- **Concurrent Safety**: All operations are thread-safe with proper mutex usage
- **Cleanup Timers**: Automatic cleanup of expired chests prevents memory leaks
- **Batch Operations**: Support for bulk loot table and spawn assignments
## Client Version Compatibility
The packet building system supports multiple EverQuest II client versions:
- **Version 1**: Basic loot display (oldest clients)
- **Version 373**: Added item type and icon support
- **Version 546**: Enhanced item appearance data
- **Version 1193**: Heirloom and no-trade flags
- **Version 60114**: Full modern feature set with adornments
## Migration from C++
This Go implementation maintains full compatibility with the original C++ EQ2EMu loot system:
- **Database Schema**: Identical table structure and data
- **Loot Algorithms**: Same probability calculations and item selection
- **Chest Logic**: Equivalent chest appearance and interaction rules
- **Global Loot**: Compatible global loot table processing
- **Packet Format**: Maintains client protocol compatibility
## Testing
Comprehensive test suite covers:
- Loot generation algorithms
- Database operations
- Chest interactions
- Packet building
- Statistics tracking
- Performance benchmarks
Run tests with:
```bash
go test ./internal/items/loot/...
```
## Configuration
Key configuration constants in `constants.go`:
- `DefaultMaxLootItems`: Maximum items per loot (default: 6)
- `ChestDespawnTime`: Empty chest despawn time (5 minutes)
- `ChestCleanupTime`: Force cleanup time (10 minutes)
- `LootTierCommon`: Minimum tier for chest creation
## Error Handling
The system provides detailed error reporting for:
- Missing loot tables or items
- Invalid player permissions
- Database connection issues
- Packet building failures
- Chest interaction violations
All errors are logged with appropriate prefixes (`[LOOT]`, `[CHEST]`, `[LOOT-DB]`) for easy debugging.

View File

@ -0,0 +1,518 @@
package loot
import (
"fmt"
"log"
"time"
"eq2emu/internal/items"
)
// ChestInteraction represents the different ways a player can interact with a chest
type ChestInteraction int8
const (
ChestInteractionView ChestInteraction = iota
ChestInteractionLoot
ChestInteractionLootAll
ChestInteractionDisarm
ChestInteractionLockpick
ChestInteractionClose
)
// String returns the string representation of ChestInteraction
func (ci ChestInteraction) String() string {
switch ci {
case ChestInteractionView:
return "view"
case ChestInteractionLoot:
return "loot"
case ChestInteractionLootAll:
return "loot_all"
case ChestInteractionDisarm:
return "disarm"
case ChestInteractionLockpick:
return "lockpick"
case ChestInteractionClose:
return "close"
default:
return "unknown"
}
}
// ChestInteractionResult represents the result of a chest interaction
type ChestInteractionResult struct {
Success bool `json:"success"`
Result int8 `json:"result"` // ChestResult constant
Message string `json:"message"` // Message to display to player
Items []*items.Item `json:"items"` // Items received
Coins int32 `json:"coins"` // Coins received
Experience int32 `json:"experience"` // Experience gained (for disarming/lockpicking)
ChestEmpty bool `json:"chest_empty"` // Whether chest is now empty
ChestClosed bool `json:"chest_closed"` // Whether chest should be closed
}
// ChestService handles treasure chest interactions and management
type ChestService struct {
lootManager *LootManager
playerService PlayerService
zoneService ZoneService
}
// PlayerService interface for player-related operations
type PlayerService interface {
GetPlayerPosition(playerID uint32) (x, y, z, heading float32, zoneID int32, err error)
IsPlayerInCombat(playerID uint32) bool
CanPlayerCarryItems(playerID uint32, itemCount int) bool
AddItemsToPlayer(playerID uint32, items []*items.Item) error
AddCoinsToPlayer(playerID uint32, coins int32) error
GetPlayerSkillValue(playerID uint32, skillName string) int32
AddPlayerExperience(playerID uint32, experience int32, skillName string) error
SendMessageToPlayer(playerID uint32, message string) error
}
// ZoneService interface for zone-related operations
type ZoneService interface {
GetZoneRule(zoneID int32, ruleName string) (interface{}, error)
SpawnObjectInZone(zoneID int32, appearanceID int32, x, y, z, heading float32, name string, commands []string) (int32, error)
RemoveObjectFromZone(zoneID int32, objectID int32) error
GetDistanceBetweenPoints(x1, y1, z1, x2, y2, z2 float32) float32
}
// NewChestService creates a new chest service
func NewChestService(lootManager *LootManager, playerService PlayerService, zoneService ZoneService) *ChestService {
return &ChestService{
lootManager: lootManager,
playerService: playerService,
zoneService: zoneService,
}
}
// CreateTreasureChestFromLoot creates a treasure chest at the specified location with the given loot
func (cs *ChestService) CreateTreasureChestFromLoot(spawnID int32, zoneID int32, x, y, z, heading float32,
lootResult *LootResult, lootRights []uint32) (*TreasureChest, error) {
// Check if treasure chests are enabled in this zone
enabled, err := cs.zoneService.GetZoneRule(zoneID, ConfigTreasureChestEnabled)
if err != nil {
log.Printf("%s Failed to check treasure chest rule for zone %d: %v", LogPrefixChest, zoneID, err)
} else if enabled == false {
log.Printf("%s Treasure chests disabled in zone %d", LogPrefixChest, zoneID)
return nil, nil // Not an error, just disabled
}
// Don't create chest if no loot
if lootResult.IsEmpty() {
log.Printf("%s No loot to put in treasure chest for spawn %d", LogPrefixChest, spawnID)
return nil, nil
}
// Filter items by tier (only common+ items go in chests, matching C++ ITEM_TAG_COMMON)
filteredItems := make([]*items.Item, 0)
for _, item := range lootResult.GetItems() {
if item.Details.Tier >= LootTierCommon {
filteredItems = append(filteredItems, item)
}
}
// Update loot result with filtered items
filteredResult := &LootResult{
Items: filteredItems,
Coins: lootResult.GetCoins(),
}
// Don't create chest if no qualifying items and no coins
if filteredResult.IsEmpty() {
log.Printf("%s No qualifying loot for treasure chest (tier >= %d) for spawn %d",
LogPrefixChest, LootTierCommon, spawnID)
return nil, nil
}
// Create the chest
chest, err := cs.lootManager.CreateTreasureChest(spawnID, zoneID, x, y, z, heading, filteredResult, lootRights)
if err != nil {
return nil, fmt.Errorf("failed to create treasure chest: %v", err)
}
// Spawn the chest object in the zone
chestCommands := []string{"loot", "disarm"} // TODO: Add "lockpick" if chest is locked
objectID, err := cs.zoneService.SpawnObjectInZone(zoneID, chest.AppearanceID, x, y, z, heading,
"Treasure Chest", chestCommands)
if err != nil {
log.Printf("%s Failed to spawn chest object in zone: %v", LogPrefixChest, err)
// Continue anyway, chest exists in memory
} else {
log.Printf("%s Spawned treasure chest object %d in zone %d", LogPrefixChest, objectID, zoneID)
}
return chest, nil
}
// HandleChestInteraction processes a player's interaction with a treasure chest
func (cs *ChestService) HandleChestInteraction(chestID int32, playerID uint32,
interaction ChestInteraction, itemUniqueID int64) *ChestInteractionResult {
result := &ChestInteractionResult{
Success: false,
Items: make([]*items.Item, 0),
}
// Get the chest
chest := cs.lootManager.GetTreasureChest(chestID)
if chest == nil {
result.Result = ChestResultFailed
result.Message = "Treasure chest not found"
return result
}
// Basic validation
if validationResult := cs.validateChestInteraction(chest, playerID); validationResult != nil {
return validationResult
}
// Process the specific interaction
switch interaction {
case ChestInteractionView:
return cs.handleViewChest(chest, playerID)
case ChestInteractionLoot:
return cs.handleLootItem(chest, playerID, itemUniqueID)
case ChestInteractionLootAll:
return cs.handleLootAll(chest, playerID)
case ChestInteractionDisarm:
return cs.handleDisarmChest(chest, playerID)
case ChestInteractionLockpick:
return cs.handleLockpickChest(chest, playerID)
case ChestInteractionClose:
return cs.handleCloseChest(chest, playerID)
default:
result.Result = ChestResultFailed
result.Message = "Unknown chest interaction"
return result
}
}
// validateChestInteraction performs basic validation for chest interactions
func (cs *ChestService) validateChestInteraction(chest *TreasureChest, playerID uint32) *ChestInteractionResult {
// Check loot rights
if !chest.HasLootRights(playerID) {
return &ChestInteractionResult{
Success: false,
Result: ChestResultNoRights,
Message: "You do not have rights to loot this chest",
}
}
// Check if player is in combat
if cs.playerService.IsPlayerInCombat(playerID) {
return &ChestInteractionResult{
Success: false,
Result: ChestResultInCombat,
Message: "You cannot loot while in combat",
}
}
// Check distance
px, py, pz, _, pZoneID, err := cs.playerService.GetPlayerPosition(playerID)
if err != nil {
return &ChestInteractionResult{
Success: false,
Result: ChestResultFailed,
Message: "Failed to get player position",
}
}
if pZoneID != chest.ZoneID {
return &ChestInteractionResult{
Success: false,
Result: ChestResultTooFar,
Message: "You are too far from the chest",
}
}
distance := cs.zoneService.GetDistanceBetweenPoints(px, py, pz, chest.X, chest.Y, chest.Z)
if distance > 10.0 { // TODO: Make this configurable
return &ChestInteractionResult{
Success: false,
Result: ChestResultTooFar,
Message: "You are too far from the chest",
}
}
// Check if chest is locked
if chest.IsLocked {
return &ChestInteractionResult{
Success: false,
Result: ChestResultLocked,
Message: "The chest is locked",
}
}
// Check if chest is trapped
if chest.IsDisarmable {
return &ChestInteractionResult{
Success: false,
Result: ChestResultTrapped,
Message: "The chest appears to be trapped",
}
}
return nil // Validation passed
}
// handleViewChest handles viewing chest contents
func (cs *ChestService) handleViewChest(chest *TreasureChest, playerID uint32) *ChestInteractionResult {
if chest.LootResult.IsEmpty() {
return &ChestInteractionResult{
Success: true,
Result: ChestResultEmpty,
Message: "The chest is empty",
ChestEmpty: true,
}
}
return &ChestInteractionResult{
Success: true,
Result: ChestResultSuccess,
Message: fmt.Sprintf("The chest contains %d items and %d coins",
len(chest.LootResult.GetItems()), chest.LootResult.GetCoins()),
Items: chest.LootResult.GetItems(),
Coins: chest.LootResult.GetCoins(),
}
}
// handleLootItem handles looting a specific item from the chest
func (cs *ChestService) handleLootItem(chest *TreasureChest, playerID uint32, itemUniqueID int64) *ChestInteractionResult {
// Check if player can carry more items
if !cs.playerService.CanPlayerCarryItems(playerID, 1) {
return &ChestInteractionResult{
Success: false,
Result: ChestResultCantCarry,
Message: "Your inventory is full",
}
}
// Loot the specific item
item, err := cs.lootManager.LootChestItem(chest.ID, playerID, itemUniqueID)
if err != nil {
return &ChestInteractionResult{
Success: false,
Result: ChestResultFailed,
Message: fmt.Sprintf("Failed to loot item: %v", err),
}
}
// Add item to player's inventory
if err := cs.playerService.AddItemsToPlayer(playerID, []*items.Item{item}); err != nil {
log.Printf("%s Failed to add looted item to player %d: %v", LogPrefixChest, playerID, err)
// TODO: Put item back in chest?
return &ChestInteractionResult{
Success: false,
Result: ChestResultFailed,
Message: "Failed to add item to inventory",
}
}
// Send message to player
message := fmt.Sprintf("You looted %s", item.Name)
cs.playerService.SendMessageToPlayer(playerID, message)
return &ChestInteractionResult{
Success: true,
Result: ChestResultSuccess,
Message: message,
Items: []*items.Item{item},
ChestEmpty: cs.lootManager.IsChestEmpty(chest.ID),
}
}
// handleLootAll handles looting all items and coins from the chest
func (cs *ChestService) handleLootAll(chest *TreasureChest, playerID uint32) *ChestInteractionResult {
lootResult, err := cs.lootManager.LootChestAll(chest.ID, playerID)
if err != nil {
return &ChestInteractionResult{
Success: false,
Result: ChestResultFailed,
Message: fmt.Sprintf("Failed to loot chest: %v", err),
}
}
if lootResult.IsEmpty() {
return &ChestInteractionResult{
Success: true,
Result: ChestResultEmpty,
Message: "The chest is empty",
ChestEmpty: true,
}
}
// Check if player can carry all items
if !cs.playerService.CanPlayerCarryItems(playerID, len(lootResult.Items)) {
// TODO: Partial loot or put items back?
return &ChestInteractionResult{
Success: false,
Result: ChestResultCantCarry,
Message: "Your inventory is full",
}
}
// Add items to player's inventory
if len(lootResult.Items) > 0 {
if err := cs.playerService.AddItemsToPlayer(playerID, lootResult.Items); err != nil {
log.Printf("%s Failed to add looted items to player %d: %v", LogPrefixChest, playerID, err)
return &ChestInteractionResult{
Success: false,
Result: ChestResultFailed,
Message: "Failed to add items to inventory",
}
}
}
// Add coins to player
if lootResult.Coins > 0 {
if err := cs.playerService.AddCoinsToPlayer(playerID, lootResult.Coins); err != nil {
log.Printf("%s Failed to add looted coins to player %d: %v", LogPrefixChest, playerID, err)
}
}
// Send message to player
message := fmt.Sprintf("You looted %d items and %d coins", len(lootResult.Items), lootResult.Coins)
cs.playerService.SendMessageToPlayer(playerID, message)
return &ChestInteractionResult{
Success: true,
Result: ChestResultSuccess,
Message: message,
Items: lootResult.Items,
Coins: lootResult.Coins,
ChestEmpty: true,
}
}
// handleDisarmChest handles disarming a trapped chest
func (cs *ChestService) handleDisarmChest(chest *TreasureChest, playerID uint32) *ChestInteractionResult {
if !chest.IsDisarmable {
return &ChestInteractionResult{
Success: false,
Result: ChestResultFailed,
Message: "This chest is not trapped",
}
}
// Get player's disarm skill
disarmSkill := cs.playerService.GetPlayerSkillValue(playerID, "Disarm Trap")
// Calculate success chance (simplified)
successChance := float32(disarmSkill) - float32(chest.DisarmDifficulty)
if successChance < 0 {
successChance = 0
} else if successChance > 95 {
successChance = 95
}
// Roll for success
roll := float32(time.Now().UnixNano()%100) // Simple random
if roll > successChance {
// Failed disarm - could trigger trap effects here
return &ChestInteractionResult{
Success: false,
Result: ChestResultFailed,
Message: "You failed to disarm the trap",
}
}
// Success - disarm the trap
chest.IsDisarmable = false
// Give experience
experience := int32(chest.DisarmDifficulty * 10) // 10 exp per difficulty point
cs.playerService.AddPlayerExperience(playerID, experience, "Disarm Trap")
message := "You successfully disarmed the trap"
cs.playerService.SendMessageToPlayer(playerID, message)
return &ChestInteractionResult{
Success: true,
Result: ChestResultSuccess,
Message: message,
Experience: experience,
}
}
// handleLockpickChest handles picking a locked chest
func (cs *ChestService) handleLockpickChest(chest *TreasureChest, playerID uint32) *ChestInteractionResult {
if !chest.IsLocked {
return &ChestInteractionResult{
Success: false,
Result: ChestResultFailed,
Message: "This chest is not locked",
}
}
// Get player's lockpicking skill
lockpickSkill := cs.playerService.GetPlayerSkillValue(playerID, "Pick Lock")
// Calculate success chance (simplified)
successChance := float32(lockpickSkill) - float32(chest.LockpickDifficulty)
if successChance < 0 {
successChance = 0
} else if successChance > 95 {
successChance = 95
}
// Roll for success
roll := float32(time.Now().UnixNano()%100) // Simple random
if roll > successChance {
return &ChestInteractionResult{
Success: false,
Result: ChestResultFailed,
Message: "You failed to pick the lock",
}
}
// Success - unlock the chest
chest.IsLocked = false
// Give experience
experience := int32(chest.LockpickDifficulty * 10) // 10 exp per difficulty point
cs.playerService.AddPlayerExperience(playerID, experience, "Pick Lock")
message := "You successfully picked the lock"
cs.playerService.SendMessageToPlayer(playerID, message)
return &ChestInteractionResult{
Success: true,
Result: ChestResultSuccess,
Message: message,
Experience: experience,
}
}
// handleCloseChest handles closing the chest interface
func (cs *ChestService) handleCloseChest(chest *TreasureChest, playerID uint32) *ChestInteractionResult {
return &ChestInteractionResult{
Success: true,
Result: ChestResultSuccess,
Message: "Closed chest",
ChestClosed: true,
}
}
// CleanupEmptyChests removes empty chests from zones
func (cs *ChestService) CleanupEmptyChests(zoneID int32) {
chests := cs.lootManager.GetZoneChests(zoneID)
for _, chest := range chests {
if chest.LootResult.IsEmpty() {
// Remove from zone
cs.zoneService.RemoveObjectFromZone(zoneID, chest.ID)
// Remove from loot manager
cs.lootManager.RemoveTreasureChest(chest.ID)
}
}
}
// GetPlayerChestList returns a list of chests a player can access
func (cs *ChestService) GetPlayerChestList(playerID uint32) []*TreasureChest {
return cs.lootManager.GetPlayerChests(playerID)
}

View File

@ -0,0 +1,199 @@
package loot
// Loot tier constants based on EQ2 item quality system
const (
LootTierTrash int8 = 0 // Gray items
LootTierCommon int8 = 1 // White items
LootTierUncommon int8 = 2 // Green items
LootTierTreasured int8 = 3 // Blue items
LootTierRare int8 = 4 // Purple items
LootTierLegendary int8 = 5 // Orange items
LootTierFabled int8 = 6 // Yellow items
LootTierMythical int8 = 7 // Red items
LootTierArtifact int8 = 8 // Artifact items
LootTierRelic int8 = 9 // Relic items
LootTierUltimate int8 = 10 // Ultimate items
)
// Chest appearance IDs from the C++ implementation
const (
ChestAppearanceSmall int32 = 4034 // Small chest for common+ items
ChestAppearanceTreasure int32 = 5864 // Treasure chest for treasured+ items
ChestAppearanceOrnate int32 = 5865 // Ornate chest for legendary+ items
ChestAppearanceExquisite int32 = 4015 // Exquisite chest for fabled+ items
)
// Loot generation constants
const (
DefaultMaxLootItems int16 = 6 // Default maximum items per loot
DefaultLootDropProbability float32 = 100.0 // Default probability for loot to drop
DefaultCoinProbability float32 = 50.0 // Default probability for coin drops
MaxGlobalLootTables int = 1000 // Maximum number of global loot tables
)
// Database table names
const (
TableLootTable = "loottable"
TableLootDrop = "lootdrop"
TableSpawnLoot = "spawn_loot"
TableLootGlobal = "loot_global"
TableLootTables = "loot_tables" // Alternative name
TableLootDrops = "loot_drops" // Alternative name
TableSpawnLootList = "spawn_loot_list" // Alternative name
)
// Database column names for loot tables
const (
ColLootTableID = "id"
ColLootTableName = "name"
ColLootTableMinCoin = "mincoin"
ColLootTableMaxCoin = "maxcoin"
ColLootTableMaxItems = "maxlootitems"
ColLootTableDropProb = "lootdrop_probability"
ColLootTableCoinProb = "coin_probability"
)
// Database column names for loot drops
const (
ColLootDropTableID = "loot_table_id"
ColLootDropItemID = "item_id"
ColLootDropCharges = "item_charges"
ColLootDropEquip = "equip_item"
ColLootDropProb = "probability"
ColLootDropQuestID = "no_drop_quest_completed_id"
)
// Database column names for spawn loot
const (
ColSpawnLootSpawnID = "spawn_id"
ColSpawnLootTableID = "loottable_id"
)
// Database column names for global loot
const (
ColGlobalLootType = "type"
ColGlobalLootTable = "loot_table"
ColGlobalLootValue1 = "value1"
ColGlobalLootValue2 = "value2"
ColGlobalLootValue3 = "value3"
ColGlobalLootValue4 = "value4"
)
// Loot flags and special values
const (
LootFlagNoTrade uint32 = 1 << 0 // Item cannot be traded
LootFlagHeirloom uint32 = 1 << 1 // Item is heirloom (account bound)
LootFlagTemporary uint32 = 1 << 2 // Item is temporary
LootFlagNoValue uint32 = 1 << 3 // Item has no coin value
LootFlagNoZone uint32 = 1 << 4 // Item cannot leave zone
LootFlagNoDestroy uint32 = 1 << 5 // Item cannot be destroyed
LootFlagCrafted uint32 = 1 << 6 // Item is crafted
LootFlagArtisan uint32 = 1 << 7 // Item requires artisan skill
LootFlagAntique uint32 = 1 << 8 // Item is antique
LootFlagMagic uint32 = 1 << 9 // Item is magic
LootFlagLegendary uint32 = 1 << 10 // Item is legendary
LootFlagDroppable uint32 = 1 << 11 // Item can be dropped
LootFlagEquipped uint32 = 1 << 12 // Item starts equipped
LootFlagVisible uint32 = 1 << 13 // Item is visible
LootFlagUnique uint32 = 1 << 14 // Only one can be owned
LootFlagLore uint32 = 1 << 15 // Item has lore restrictions
)
// Special loot table IDs
const (
LootTableIDNone int32 = 0 // No loot table
LootTableIDGlobal int32 = -1 // Global loot table marker
LootTableIDLevel int32 = -2 // Level-based global loot
LootTableIDRace int32 = -3 // Race-based global loot
LootTableIDZone int32 = -4 // Zone-based global loot
)
// Loot command types
const (
LootCommandView = "view" // View chest contents
LootCommandTake = "take" // Take specific item
LootCommandTakeAll = "take_all" // Take all items
LootCommandClose = "close" // Close loot window
LootCommandDisarm = "disarm" // Disarm chest trap
LootCommandLockpick = "lockpick" // Pick chest lock
)
// Chest interaction results
const (
ChestResultSuccess = 0 // Operation successful
ChestResultLocked = 1 // Chest is locked
ChestResultTrapped = 2 // Chest is trapped
ChestResultNoRights = 3 // No loot rights
ChestResultEmpty = 4 // Chest is empty
ChestResultFailed = 5 // Operation failed
ChestResultCantCarry = 6 // Cannot carry more items
ChestResultTooFar = 7 // Too far from chest
ChestResultInCombat = 8 // Cannot loot while in combat
)
// Loot distribution methods
const (
LootDistributionNone = 0 // No automatic distribution
LootDistributionFreeForAll = 1 // Anyone can loot
LootDistributionRoundRobin = 2 // Round robin distribution
LootDistributionMasterLoot = 3 // Master looter decides
LootDistributionNeedGreed = 4 // Need before greed system
LootDistributionLotto = 5 // Random lotto system
)
// Loot quality thresholds for different distribution methods
const (
NeedGreedThreshold int8 = LootTierTreasured // Blue+ items use need/greed
MasterLootThreshold int8 = LootTierRare // Purple+ items go to master looter
LottoThreshold int8 = LootTierLegendary // Orange+ items use lotto system
)
// Chest spawn duration and cleanup
const (
ChestDespawnTime = 300 // Seconds before chest despawns (5 minutes)
ChestCleanupTime = 600 // Seconds before chest is force-cleaned (10 minutes)
MaxChestsPerZone = 100 // Maximum number of chests per zone
MaxChestsPerPlayer = 10 // Maximum number of chests a player can have loot rights to
)
// Probability calculation constants
const (
ProbabilityMax float32 = 100.0 // Maximum probability percentage
ProbabilityMin float32 = 0.0 // Minimum probability percentage
ProbabilityDefault float32 = 50.0 // Default probability for items
)
// Error messages
const (
ErrLootTableNotFound = "loot table not found"
ErrNoLootRights = "no loot rights for this chest"
ErrChestLocked = "chest is locked"
ErrChestTrapped = "chest is trapped"
ErrInventoryFull = "inventory is full"
ErrTooFarFromChest = "too far from chest"
ErrInCombat = "cannot loot while in combat"
ErrInvalidLootTable = "invalid loot table"
ErrInvalidItem = "invalid item in loot table"
ErrDatabaseError = "database error during loot operation"
)
// Logging prefixes
const (
LogPrefixLoot = "[LOOT]"
LogPrefixChest = "[CHEST]"
LogPrefixDatabase = "[LOOT-DB]"
LogPrefixGeneration = "[LOOT-GEN]"
)
// Configuration keys for loot system
const (
ConfigTreasureChestEnabled = "treasure_chest_enabled"
ConfigGlobalLootEnabled = "global_loot_enabled"
ConfigLootStatisticsEnabled = "loot_statistics_enabled"
ConfigChestDespawnTime = "chest_despawn_time"
ConfigMaxChestsPerZone = "max_chests_per_zone"
ConfigDefaultLootProbability = "default_loot_probability"
ConfigDefaultCoinProbability = "default_coin_probability"
ConfigLootDistanceCheck = "loot_distance_check"
ConfigLootCombatCheck = "loot_combat_check"
)

View File

@ -0,0 +1,644 @@
package loot
import (
"database/sql"
"fmt"
"log"
"sync"
"time"
)
// LootDatabase handles all database operations for the loot system
type LootDatabase struct {
db *sql.DB
queries map[string]*sql.Stmt
lootTables map[int32]*LootTable
spawnLoot map[int32][]int32 // spawn_id -> []loot_table_id
globalLoot []*GlobalLoot
mutex sync.RWMutex
}
// NewLootDatabase creates a new loot database manager
func NewLootDatabase(db *sql.DB) *LootDatabase {
ldb := &LootDatabase{
db: db,
queries: make(map[string]*sql.Stmt),
lootTables: make(map[int32]*LootTable),
spawnLoot: make(map[int32][]int32),
globalLoot: make([]*GlobalLoot, 0),
}
// Prepare commonly used queries
ldb.prepareQueries()
return ldb
}
// prepareQueries prepares all commonly used SQL queries
func (ldb *LootDatabase) prepareQueries() {
queries := map[string]string{
"load_loot_tables": `
SELECT id, name, mincoin, maxcoin, maxlootitems, lootdrop_probability, coin_probability
FROM loottable
ORDER BY id
`,
"load_loot_drops": `
SELECT loot_table_id, item_id, item_charges, equip_item, probability, no_drop_quest_completed_id
FROM lootdrop
WHERE loot_table_id = ?
ORDER BY probability DESC
`,
"load_spawn_loot": `
SELECT spawn_id, loottable_id
FROM spawn_loot
ORDER BY spawn_id
`,
"load_global_loot": `
SELECT type, loot_table, value1, value2, value3, value4
FROM loot_global
ORDER BY type, value1
`,
"insert_loot_table": `
INSERT INTO loottable (id, name, mincoin, maxcoin, maxlootitems, lootdrop_probability, coin_probability)
VALUES (?, ?, ?, ?, ?, ?, ?)
`,
"update_loot_table": `
UPDATE loottable
SET name = ?, mincoin = ?, maxcoin = ?, maxlootitems = ?, lootdrop_probability = ?, coin_probability = ?
WHERE id = ?
`,
"delete_loot_table": `
DELETE FROM loottable WHERE id = ?
`,
"insert_loot_drop": `
INSERT INTO lootdrop (loot_table_id, item_id, item_charges, equip_item, probability, no_drop_quest_completed_id)
VALUES (?, ?, ?, ?, ?, ?)
`,
"delete_loot_drops": `
DELETE FROM lootdrop WHERE loot_table_id = ?
`,
"insert_spawn_loot": `
INSERT OR REPLACE INTO spawn_loot (spawn_id, loottable_id)
VALUES (?, ?)
`,
"delete_spawn_loot": `
DELETE FROM spawn_loot WHERE spawn_id = ?
`,
"insert_global_loot": `
INSERT INTO loot_global (type, loot_table, value1, value2, value3, value4)
VALUES (?, ?, ?, ?, ?, ?)
`,
"delete_global_loot": `
DELETE FROM loot_global WHERE type = ?
`,
"get_loot_table": `
SELECT id, name, mincoin, maxcoin, maxlootitems, lootdrop_probability, coin_probability
FROM loottable
WHERE id = ?
`,
"get_spawn_loot_tables": `
SELECT loottable_id
FROM spawn_loot
WHERE spawn_id = ?
`,
"count_loot_tables": `
SELECT COUNT(*) FROM loottable
`,
"count_loot_drops": `
SELECT COUNT(*) FROM lootdrop
`,
"count_spawn_loot": `
SELECT COUNT(*) FROM spawn_loot
`,
}
for name, query := range queries {
if stmt, err := ldb.db.Prepare(query); err != nil {
log.Printf("%s Failed to prepare query %s: %v", LogPrefixDatabase, name, err)
} else {
ldb.queries[name] = stmt
}
}
}
// LoadAllLootData loads all loot data from the database
func (ldb *LootDatabase) LoadAllLootData() error {
log.Printf("%s Loading loot data from database...", LogPrefixDatabase)
// Load loot tables first
if err := ldb.loadLootTables(); err != nil {
return fmt.Errorf("failed to load loot tables: %v", err)
}
// Load loot drops for each table
if err := ldb.loadLootDrops(); err != nil {
return fmt.Errorf("failed to load loot drops: %v", err)
}
// Load spawn loot assignments
if err := ldb.loadSpawnLoot(); err != nil {
return fmt.Errorf("failed to load spawn loot: %v", err)
}
// Load global loot configuration
if err := ldb.loadGlobalLoot(); err != nil {
return fmt.Errorf("failed to load global loot: %v", err)
}
ldb.mutex.RLock()
tableCount := len(ldb.lootTables)
spawnCount := len(ldb.spawnLoot)
globalCount := len(ldb.globalLoot)
ldb.mutex.RUnlock()
log.Printf("%s Loaded %d loot tables, %d spawn assignments, %d global loot entries",
LogPrefixDatabase, tableCount, spawnCount, globalCount)
return nil
}
// loadLootTables loads all loot tables from the database
func (ldb *LootDatabase) loadLootTables() error {
stmt := ldb.queries["load_loot_tables"]
if stmt == nil {
return fmt.Errorf("load_loot_tables query not prepared")
}
rows, err := stmt.Query()
if err != nil {
return fmt.Errorf("failed to query loot tables: %v", err)
}
defer rows.Close()
ldb.mutex.Lock()
defer ldb.mutex.Unlock()
// Clear existing tables
ldb.lootTables = make(map[int32]*LootTable)
for rows.Next() {
table := &LootTable{
Drops: make([]*LootDrop, 0),
}
err := rows.Scan(
&table.ID,
&table.Name,
&table.MinCoin,
&table.MaxCoin,
&table.MaxLootItems,
&table.LootDropProbability,
&table.CoinProbability,
)
if err != nil {
log.Printf("%s Error scanning loot table row: %v", LogPrefixDatabase, err)
continue
}
ldb.lootTables[table.ID] = table
}
return rows.Err()
}
// loadLootDrops loads all loot drops for the loaded loot tables
func (ldb *LootDatabase) loadLootDrops() error {
stmt := ldb.queries["load_loot_drops"]
if stmt == nil {
return fmt.Errorf("load_loot_drops query not prepared")
}
ldb.mutex.Lock()
defer ldb.mutex.Unlock()
for tableID, table := range ldb.lootTables {
rows, err := stmt.Query(tableID)
if err != nil {
log.Printf("%s Failed to query loot drops for table %d: %v", LogPrefixDatabase, tableID, err)
continue
}
for rows.Next() {
drop := &LootDrop{}
var equipItem int8
err := rows.Scan(
&drop.LootTableID,
&drop.ItemID,
&drop.ItemCharges,
&equipItem,
&drop.Probability,
&drop.NoDropQuestCompletedID,
)
if err != nil {
log.Printf("%s Error scanning loot drop row: %v", LogPrefixDatabase, err)
continue
}
drop.EquipItem = equipItem == 1
table.Drops = append(table.Drops, drop)
}
rows.Close()
}
return nil
}
// loadSpawnLoot loads spawn to loot table assignments
func (ldb *LootDatabase) loadSpawnLoot() error {
stmt := ldb.queries["load_spawn_loot"]
if stmt == nil {
return fmt.Errorf("load_spawn_loot query not prepared")
}
rows, err := stmt.Query()
if err != nil {
return fmt.Errorf("failed to query spawn loot: %v", err)
}
defer rows.Close()
ldb.mutex.Lock()
defer ldb.mutex.Unlock()
// Clear existing spawn loot
ldb.spawnLoot = make(map[int32][]int32)
for rows.Next() {
var spawnID, lootTableID int32
err := rows.Scan(&spawnID, &lootTableID)
if err != nil {
log.Printf("%s Error scanning spawn loot row: %v", LogPrefixDatabase, err)
continue
}
ldb.spawnLoot[spawnID] = append(ldb.spawnLoot[spawnID], lootTableID)
}
return rows.Err()
}
// loadGlobalLoot loads global loot configuration
func (ldb *LootDatabase) loadGlobalLoot() error {
stmt := ldb.queries["load_global_loot"]
if stmt == nil {
return fmt.Errorf("load_global_loot query not prepared")
}
rows, err := stmt.Query()
if err != nil {
return fmt.Errorf("failed to query global loot: %v", err)
}
defer rows.Close()
ldb.mutex.Lock()
defer ldb.mutex.Unlock()
// Clear existing global loot
ldb.globalLoot = make([]*GlobalLoot, 0)
for rows.Next() {
var lootType string
var tableID, value1, value2, value3, value4 int32
err := rows.Scan(&lootType, &tableID, &value1, &value2, &value3, &value4)
if err != nil {
log.Printf("%s Error scanning global loot row: %v", LogPrefixDatabase, err)
continue
}
global := &GlobalLoot{
TableID: tableID,
}
// Parse loot type and values
switch lootType {
case "level":
global.Type = GlobalLootTypeLevel
global.MinLevel = int8(value1)
global.MaxLevel = int8(value2)
global.LootTier = value3
case "race":
global.Type = GlobalLootTypeRace
global.Race = int16(value1)
global.LootTier = value2
case "zone":
global.Type = GlobalLootTypeZone
global.ZoneID = value1
global.LootTier = value2
default:
log.Printf("%s Unknown global loot type: %s", LogPrefixDatabase, lootType)
continue
}
ldb.globalLoot = append(ldb.globalLoot, global)
}
return rows.Err()
}
// GetLootTable returns a loot table by ID (thread-safe)
func (ldb *LootDatabase) GetLootTable(tableID int32) *LootTable {
ldb.mutex.RLock()
defer ldb.mutex.RUnlock()
return ldb.lootTables[tableID]
}
// GetSpawnLootTables returns all loot table IDs for a spawn (thread-safe)
func (ldb *LootDatabase) GetSpawnLootTables(spawnID int32) []int32 {
ldb.mutex.RLock()
defer ldb.mutex.RUnlock()
tables := ldb.spawnLoot[spawnID]
if tables == nil {
return nil
}
// Return a copy to prevent external modification
result := make([]int32, len(tables))
copy(result, tables)
return result
}
// GetGlobalLootTables returns applicable global loot tables for given parameters
func (ldb *LootDatabase) GetGlobalLootTables(level int16, race int16, zoneID int32) []*GlobalLoot {
ldb.mutex.RLock()
defer ldb.mutex.RUnlock()
var result []*GlobalLoot
for _, global := range ldb.globalLoot {
switch global.Type {
case GlobalLootTypeLevel:
if level >= int16(global.MinLevel) && level <= int16(global.MaxLevel) {
result = append(result, global)
}
case GlobalLootTypeRace:
if race == global.Race {
result = append(result, global)
}
case GlobalLootTypeZone:
if zoneID == global.ZoneID {
result = append(result, global)
}
}
}
return result
}
// AddLootTable adds a new loot table to the database
func (ldb *LootDatabase) AddLootTable(table *LootTable) error {
stmt := ldb.queries["insert_loot_table"]
if stmt == nil {
return fmt.Errorf("insert_loot_table query not prepared")
}
_, err := stmt.Exec(
table.ID,
table.Name,
table.MinCoin,
table.MaxCoin,
table.MaxLootItems,
table.LootDropProbability,
table.CoinProbability,
)
if err != nil {
return fmt.Errorf("failed to insert loot table: %v", err)
}
// Add drops if any
for _, drop := range table.Drops {
if err := ldb.AddLootDrop(drop); err != nil {
log.Printf("%s Failed to add loot drop for table %d: %v", LogPrefixDatabase, table.ID, err)
}
}
// Update in-memory cache
ldb.mutex.Lock()
ldb.lootTables[table.ID] = table
ldb.mutex.Unlock()
log.Printf("%s Added loot table %d (%s) with %d drops", LogPrefixDatabase, table.ID, table.Name, len(table.Drops))
return nil
}
// AddLootDrop adds a new loot drop to the database
func (ldb *LootDatabase) AddLootDrop(drop *LootDrop) error {
stmt := ldb.queries["insert_loot_drop"]
if stmt == nil {
return fmt.Errorf("insert_loot_drop query not prepared")
}
equipItem := int8(0)
if drop.EquipItem {
equipItem = 1
}
_, err := stmt.Exec(
drop.LootTableID,
drop.ItemID,
drop.ItemCharges,
equipItem,
drop.Probability,
drop.NoDropQuestCompletedID,
)
return err
}
// UpdateLootTable updates an existing loot table
func (ldb *LootDatabase) UpdateLootTable(table *LootTable) error {
stmt := ldb.queries["update_loot_table"]
if stmt == nil {
return fmt.Errorf("update_loot_table query not prepared")
}
_, err := stmt.Exec(
table.Name,
table.MinCoin,
table.MaxCoin,
table.MaxLootItems,
table.LootDropProbability,
table.CoinProbability,
table.ID,
)
if err != nil {
return fmt.Errorf("failed to update loot table: %v", err)
}
// Update drops - delete old ones and insert new ones
if err := ldb.DeleteLootDrops(table.ID); err != nil {
log.Printf("%s Failed to delete old loot drops for table %d: %v", LogPrefixDatabase, table.ID, err)
}
for _, drop := range table.Drops {
if err := ldb.AddLootDrop(drop); err != nil {
log.Printf("%s Failed to add updated loot drop for table %d: %v", LogPrefixDatabase, table.ID, err)
}
}
// Update in-memory cache
ldb.mutex.Lock()
ldb.lootTables[table.ID] = table
ldb.mutex.Unlock()
return nil
}
// DeleteLootTable removes a loot table and all its drops
func (ldb *LootDatabase) DeleteLootTable(tableID int32) error {
// Delete drops first
if err := ldb.DeleteLootDrops(tableID); err != nil {
return fmt.Errorf("failed to delete loot drops: %v", err)
}
// Delete table
stmt := ldb.queries["delete_loot_table"]
if stmt == nil {
return fmt.Errorf("delete_loot_table query not prepared")
}
_, err := stmt.Exec(tableID)
if err != nil {
return fmt.Errorf("failed to delete loot table: %v", err)
}
// Remove from in-memory cache
ldb.mutex.Lock()
delete(ldb.lootTables, tableID)
ldb.mutex.Unlock()
return nil
}
// DeleteLootDrops removes all drops for a loot table
func (ldb *LootDatabase) DeleteLootDrops(tableID int32) error {
stmt := ldb.queries["delete_loot_drops"]
if stmt == nil {
return fmt.Errorf("delete_loot_drops query not prepared")
}
_, err := stmt.Exec(tableID)
return err
}
// AddSpawnLoot assigns a loot table to a spawn
func (ldb *LootDatabase) AddSpawnLoot(spawnID, tableID int32) error {
stmt := ldb.queries["insert_spawn_loot"]
if stmt == nil {
return fmt.Errorf("insert_spawn_loot query not prepared")
}
_, err := stmt.Exec(spawnID, tableID)
if err != nil {
return fmt.Errorf("failed to insert spawn loot: %v", err)
}
// Update in-memory cache
ldb.mutex.Lock()
ldb.spawnLoot[spawnID] = append(ldb.spawnLoot[spawnID], tableID)
ldb.mutex.Unlock()
return nil
}
// DeleteSpawnLoot removes all loot table assignments for a spawn
func (ldb *LootDatabase) DeleteSpawnLoot(spawnID int32) error {
stmt := ldb.queries["delete_spawn_loot"]
if stmt == nil {
return fmt.Errorf("delete_spawn_loot query not prepared")
}
_, err := stmt.Exec(spawnID)
if err != nil {
return fmt.Errorf("failed to delete spawn loot: %v", err)
}
// Remove from in-memory cache
ldb.mutex.Lock()
delete(ldb.spawnLoot, spawnID)
ldb.mutex.Unlock()
return nil
}
// GetLootStatistics returns database statistics
func (ldb *LootDatabase) GetLootStatistics() (map[string]interface{}, error) {
stats := make(map[string]interface{})
// Count loot tables
if stmt := ldb.queries["count_loot_tables"]; stmt != nil {
var count int
if err := stmt.QueryRow().Scan(&count); err == nil {
stats["loot_tables"] = count
}
}
// Count loot drops
if stmt := ldb.queries["count_loot_drops"]; stmt != nil {
var count int
if err := stmt.QueryRow().Scan(&count); err == nil {
stats["loot_drops"] = count
}
}
// Count spawn loot assignments
if stmt := ldb.queries["count_spawn_loot"]; stmt != nil {
var count int
if err := stmt.QueryRow().Scan(&count); err == nil {
stats["spawn_loot_assignments"] = count
}
}
// In-memory statistics
ldb.mutex.RLock()
stats["cached_loot_tables"] = len(ldb.lootTables)
stats["cached_spawn_assignments"] = len(ldb.spawnLoot)
stats["cached_global_loot"] = len(ldb.globalLoot)
ldb.mutex.RUnlock()
stats["loaded_at"] = time.Now().Format(time.RFC3339)
return stats, nil
}
// ReloadLootData reloads all loot data from the database
func (ldb *LootDatabase) ReloadLootData() error {
log.Printf("%s Reloading loot data from database...", LogPrefixDatabase)
return ldb.LoadAllLootData()
}
// Close closes all prepared statements
func (ldb *LootDatabase) Close() error {
for name, stmt := range ldb.queries {
if err := stmt.Close(); err != nil {
log.Printf("%s Error closing statement %s: %v", LogPrefixDatabase, name, err)
}
}
return nil
}

View File

@ -0,0 +1,416 @@
package loot
import (
"database/sql"
"fmt"
"log"
"eq2emu/internal/items"
)
// LootSystem represents the complete loot system integration
type LootSystem struct {
Database *LootDatabase
Manager *LootManager
ChestService *ChestService
PacketService *LootPacketService
}
// LootSystemConfig holds configuration for the loot system
type LootSystemConfig struct {
DatabaseConnection *sql.DB
ItemMasterList items.MasterItemListService
PlayerService PlayerService
ZoneService ZoneService
ClientService ClientService
ItemPacketBuilder ItemPacketBuilder
StartCleanupTimer bool
}
// NewLootSystem creates a complete loot system with all components
func NewLootSystem(config *LootSystemConfig) (*LootSystem, error) {
if config.DatabaseConnection == nil {
return nil, fmt.Errorf("database connection is required")
}
if config.ItemMasterList == nil {
return nil, fmt.Errorf("item master list is required")
}
// Create database layer
database := NewLootDatabase(config.DatabaseConnection)
// Load loot data
if err := database.LoadAllLootData(); err != nil {
return nil, fmt.Errorf("failed to load loot data: %v", err)
}
// Create loot manager
manager := NewLootManager(database, config.ItemMasterList)
// Create chest service (optional - requires player and zone services)
var chestService *ChestService
if config.PlayerService != nil && config.ZoneService != nil {
chestService = NewChestService(manager, config.PlayerService, config.ZoneService)
}
// Create packet service (optional - requires client and item packet builder)
var packetService *LootPacketService
if config.ClientService != nil && config.ItemPacketBuilder != nil {
packetBuilder := NewLootPacketBuilder(config.ItemPacketBuilder)
packetService = NewLootPacketService(packetBuilder, config.ClientService)
}
// Start cleanup timer if requested
if config.StartCleanupTimer {
manager.StartCleanupTimer()
}
system := &LootSystem{
Database: database,
Manager: manager,
ChestService: chestService,
PacketService: packetService,
}
log.Printf("%s Loot system initialized successfully", LogPrefixLoot)
return system, nil
}
// GenerateAndCreateChest generates loot for a spawn and creates a treasure chest
func (ls *LootSystem) GenerateAndCreateChest(spawnID int32, zoneID int32, x, y, z, heading float32,
context *LootContext) (*TreasureChest, error) {
if ls.ChestService == nil {
return nil, fmt.Errorf("chest service not available")
}
// Generate loot
lootResult, err := ls.Manager.GenerateLoot(spawnID, context)
if err != nil {
return nil, fmt.Errorf("failed to generate loot: %v", err)
}
// Don't create chest if no loot
if lootResult.IsEmpty() {
log.Printf("%s No loot generated for spawn %d, not creating chest", LogPrefixLoot, spawnID)
return nil, nil
}
// Create treasure chest
chest, err := ls.ChestService.CreateTreasureChestFromLoot(spawnID, zoneID, x, y, z, heading,
lootResult, context.GroupMembers)
if err != nil {
return nil, fmt.Errorf("failed to create treasure chest: %v", err)
}
return chest, nil
}
// HandlePlayerLootInteraction handles a player's interaction with a chest and sends appropriate packets
func (ls *LootSystem) HandlePlayerLootInteraction(chestID int32, playerID uint32,
interaction ChestInteraction, itemUniqueID int64) error {
if ls.ChestService == nil {
return fmt.Errorf("chest service not available")
}
// Handle the interaction
result := ls.ChestService.HandleChestInteraction(chestID, playerID, interaction, itemUniqueID)
// Send response packet if packet service is available
if ls.PacketService != nil {
if err := ls.PacketService.SendLootResponse(result, playerID); err != nil {
log.Printf("%s Failed to send loot response packet: %v", LogPrefixLoot, err)
}
// Send updated loot window if chest is still open and has items
if result.Success && !result.ChestClosed {
chest := ls.Manager.GetTreasureChest(chestID)
if chest != nil && !chest.LootResult.IsEmpty() {
if err := ls.PacketService.SendLootUpdate(chest, playerID); err != nil {
log.Printf("%s Failed to send loot update packet: %v", LogPrefixLoot, err)
}
} else if chest != nil && chest.LootResult.IsEmpty() {
// Send stopped looting packet for empty chest
if err := ls.PacketService.SendStoppedLooting(chestID, playerID); err != nil {
log.Printf("%s Failed to send stopped looting packet: %v", LogPrefixLoot, err)
}
}
}
}
// Log the interaction
log.Printf("%s Player %d %s chest %d: %s",
LogPrefixLoot, playerID, interaction.String(), chestID, result.Message)
return nil
}
// ShowChestToPlayer sends the loot window to a player
func (ls *LootSystem) ShowChestToPlayer(chestID int32, playerID uint32) error {
if ls.PacketService == nil {
return fmt.Errorf("packet service not available")
}
chest := ls.Manager.GetTreasureChest(chestID)
if chest == nil {
return fmt.Errorf("chest %d not found", chestID)
}
// Check loot rights
if !chest.HasLootRights(playerID) {
return fmt.Errorf("player %d has no loot rights for chest %d", playerID, chestID)
}
// Send loot update packet
return ls.PacketService.SendLootUpdate(chest, playerID)
}
// GetSystemStatistics returns comprehensive statistics about the loot system
func (ls *LootSystem) GetSystemStatistics() (map[string]interface{}, error) {
stats := make(map[string]interface{})
// Database statistics
if dbStats, err := ls.Database.GetLootStatistics(); err == nil {
stats["database"] = dbStats
}
// Manager statistics
stats["generation"] = ls.Manager.GetStatistics()
// Active chests count
chestCount := 0
ls.Manager.mutex.RLock()
chestCount = len(ls.Manager.treasureChests)
ls.Manager.mutex.RUnlock()
stats["active_chests"] = chestCount
return stats, nil
}
// ReloadAllData reloads all loot data from the database
func (ls *LootSystem) ReloadAllData() error {
log.Printf("%s Reloading all loot system data", LogPrefixLoot)
return ls.Database.LoadAllLootData()
}
// Shutdown gracefully shuts down the loot system
func (ls *LootSystem) Shutdown() error {
log.Printf("%s Shutting down loot system", LogPrefixLoot)
// Close database connections
if err := ls.Database.Close(); err != nil {
log.Printf("%s Error closing database: %v", LogPrefixLoot, err)
return err
}
// Clear active chests
ls.Manager.mutex.Lock()
ls.Manager.treasureChests = make(map[int32]*TreasureChest)
ls.Manager.mutex.Unlock()
log.Printf("%s Loot system shutdown complete", LogPrefixLoot)
return nil
}
// AddLootTableWithDrops adds a complete loot table with drops in a single transaction
func (ls *LootSystem) AddLootTableWithDrops(table *LootTable) error {
return ls.Database.AddLootTable(table)
}
// CreateQuickLootTable creates a simple loot table with basic parameters
func (ls *LootSystem) CreateQuickLootTable(tableID int32, name string, items []QuickLootItem,
minCoin, maxCoin int32, maxItems int16) error {
table := &LootTable{
ID: tableID,
Name: name,
MinCoin: minCoin,
MaxCoin: maxCoin,
MaxLootItems: maxItems,
LootDropProbability: DefaultLootDropProbability,
CoinProbability: DefaultCoinProbability,
Drops: make([]*LootDrop, len(items)),
}
for i, item := range items {
table.Drops[i] = &LootDrop{
LootTableID: tableID,
ItemID: item.ItemID,
ItemCharges: item.Charges,
EquipItem: item.AutoEquip,
Probability: item.Probability,
}
}
return ls.AddLootTableWithDrops(table)
}
// QuickLootItem represents a simple loot item for quick table creation
type QuickLootItem struct {
ItemID int32
Charges int16
Probability float32
AutoEquip bool
}
// AssignLootToSpawns assigns a loot table to multiple spawns
func (ls *LootSystem) AssignLootToSpawns(tableID int32, spawnIDs []int32) error {
for _, spawnID := range spawnIDs {
if err := ls.Database.AddSpawnLoot(spawnID, tableID); err != nil {
return fmt.Errorf("failed to assign loot table %d to spawn %d: %v", tableID, spawnID, err)
}
}
log.Printf("%s Assigned loot table %d to %d spawns", LogPrefixLoot, tableID, len(spawnIDs))
return nil
}
// CreateGlobalLevelLoot creates global loot for a level range
func (ls *LootSystem) CreateGlobalLevelLoot(minLevel, maxLevel int8, tableID int32, tier int32) error {
global := &GlobalLoot{
Type: GlobalLootTypeLevel,
MinLevel: minLevel,
MaxLevel: maxLevel,
TableID: tableID,
LootTier: tier,
}
// Insert into database
query := `INSERT INTO loot_global (type, loot_table, value1, value2, value3, value4) VALUES (?, ?, ?, ?, ?, ?)`
_, err := ls.Database.db.Exec(query, "level", tableID, minLevel, maxLevel, tier, 0)
if err != nil {
return fmt.Errorf("failed to insert global level loot: %v", err)
}
// Add to in-memory cache
ls.Database.mutex.Lock()
ls.Database.globalLoot = append(ls.Database.globalLoot, global)
ls.Database.mutex.Unlock()
log.Printf("%s Created global level loot for levels %d-%d using table %d",
LogPrefixLoot, minLevel, maxLevel, tableID)
return nil
}
// GetActiveChestsInZone returns all active chests in a specific zone
func (ls *LootSystem) GetActiveChestsInZone(zoneID int32) []*TreasureChest {
return ls.Manager.GetZoneChests(zoneID)
}
// CleanupZoneChests removes all chests from a specific zone
func (ls *LootSystem) CleanupZoneChests(zoneID int32) {
chests := ls.Manager.GetZoneChests(zoneID)
for _, chest := range chests {
ls.Manager.RemoveTreasureChest(chest.ID)
// Remove from zone if chest service is available
if ls.ChestService != nil {
ls.ChestService.zoneService.RemoveObjectFromZone(zoneID, chest.ID)
}
}
log.Printf("%s Cleaned up %d chests from zone %d", LogPrefixLoot, len(chests), zoneID)
}
// ValidateItemsInLootTables checks that all items in loot tables exist in the item master list
func (ls *LootSystem) ValidateItemsInLootTables() []ValidationError {
var errors []ValidationError
ls.Database.mutex.RLock()
defer ls.Database.mutex.RUnlock()
for tableID, table := range ls.Database.lootTables {
for _, drop := range table.Drops {
item := ls.Manager.itemMasterList.GetItem(drop.ItemID)
if item == nil {
errors = append(errors, ValidationError{
Type: "missing_item",
TableID: tableID,
ItemID: drop.ItemID,
Description: fmt.Sprintf("Item %d in loot table %d (%s) does not exist", drop.ItemID, tableID, table.Name),
})
}
}
}
if len(errors) > 0 {
log.Printf("%s Found %d validation errors in loot tables", LogPrefixLoot, len(errors))
}
return errors
}
// ValidationError represents a loot system validation error
type ValidationError struct {
Type string `json:"type"`
TableID int32 `json:"table_id"`
ItemID int32 `json:"item_id,omitempty"`
Description string `json:"description"`
}
// GetLootPreview generates a preview of potential loot without actually creating it
func (ls *LootSystem) GetLootPreview(spawnID int32, context *LootContext) (*LootPreview, error) {
tableIDs := ls.Database.GetSpawnLootTables(spawnID)
globalLoot := ls.Database.GetGlobalLootTables(context.PlayerLevel, context.PlayerRace, context.ZoneID)
for _, global := range globalLoot {
tableIDs = append(tableIDs, global.TableID)
}
preview := &LootPreview{
SpawnID: spawnID,
TableIDs: tableIDs,
PossibleItems: make([]*LootPreviewItem, 0),
MinCoins: 0,
MaxCoins: 0,
}
for _, tableID := range tableIDs {
table := ls.Database.GetLootTable(tableID)
if table == nil {
continue
}
preview.MinCoins += table.MinCoin
preview.MaxCoins += table.MaxCoin
for _, drop := range table.Drops {
item := ls.Manager.itemMasterList.GetItem(drop.ItemID)
if item == nil {
continue
}
previewItem := &LootPreviewItem{
ItemID: drop.ItemID,
ItemName: item.Name,
Probability: drop.Probability,
Tier: item.Details.Tier,
}
preview.PossibleItems = append(preview.PossibleItems, previewItem)
}
}
return preview, nil
}
// LootPreview represents a preview of potential loot
type LootPreview struct {
SpawnID int32 `json:"spawn_id"`
TableIDs []int32 `json:"table_ids"`
PossibleItems []*LootPreviewItem `json:"possible_items"`
MinCoins int32 `json:"min_coins"`
MaxCoins int32 `json:"max_coins"`
}
// LootPreviewItem represents a potential loot item in a preview
type LootPreviewItem struct {
ItemID int32 `json:"item_id"`
ItemName string `json:"item_name"`
Probability float32 `json:"probability"`
Tier int8 `json:"tier"`
}

View File

@ -0,0 +1,670 @@
package loot
import (
"database/sql"
"testing"
"time"
"eq2emu/internal/items"
_ "zombiezen.com/go/sqlite"
)
// Test helper functions and mock implementations
// MockItemMasterList implements items.MasterItemListService for testing
type MockItemMasterList struct {
items map[int32]*items.Item
}
func NewMockItemMasterList() *MockItemMasterList {
return &MockItemMasterList{
items: make(map[int32]*items.Item),
}
}
func (m *MockItemMasterList) GetItem(itemID int32) *items.Item {
return m.items[itemID]
}
func (m *MockItemMasterList) AddTestItem(itemID int32, name string, tier int8) {
item := &items.Item{
Name: name,
Details: items.ItemDetails{
ItemID: itemID,
Tier: tier,
},
GenericInfo: items.ItemGenericInfo{
ItemType: items.ItemTypeNormal,
},
}
item.Details.UniqueID = items.NextUniqueItemID()
m.items[itemID] = item
}
// MockPlayerService implements PlayerService for testing
type MockPlayerService struct {
playerPositions map[uint32][5]float32 // x, y, z, heading, zoneID
inventorySpace map[uint32]int
combat map[uint32]bool
skills map[uint32]map[string]int32
}
func NewMockPlayerService() *MockPlayerService {
return &MockPlayerService{
playerPositions: make(map[uint32][5]float32),
inventorySpace: make(map[uint32]int),
combat: make(map[uint32]bool),
skills: make(map[uint32]map[string]int32),
}
}
func (m *MockPlayerService) GetPlayerPosition(playerID uint32) (x, y, z, heading float32, zoneID int32, err error) {
pos := m.playerPositions[playerID]
return pos[0], pos[1], pos[2], pos[3], int32(pos[4]), nil
}
func (m *MockPlayerService) IsPlayerInCombat(playerID uint32) bool {
return m.combat[playerID]
}
func (m *MockPlayerService) CanPlayerCarryItems(playerID uint32, itemCount int) bool {
space := m.inventorySpace[playerID]
return space >= itemCount
}
func (m *MockPlayerService) AddItemsToPlayer(playerID uint32, items []*items.Item) error {
return nil
}
func (m *MockPlayerService) AddCoinsToPlayer(playerID uint32, coins int32) error {
return nil
}
func (m *MockPlayerService) GetPlayerSkillValue(playerID uint32, skillName string) int32 {
if skills, exists := m.skills[playerID]; exists {
return skills[skillName]
}
return 0
}
func (m *MockPlayerService) AddPlayerExperience(playerID uint32, experience int32, skillName string) error {
return nil
}
func (m *MockPlayerService) SendMessageToPlayer(playerID uint32, message string) error {
return nil
}
func (m *MockPlayerService) SetPlayerPosition(playerID uint32, x, y, z, heading float32, zoneID int32) {
m.playerPositions[playerID] = [5]float32{x, y, z, heading, float32(zoneID)}
}
func (m *MockPlayerService) SetInventorySpace(playerID uint32, space int) {
m.inventorySpace[playerID] = space
}
// MockZoneService implements ZoneService for testing
type MockZoneService struct {
rules map[int32]map[string]interface{}
objects map[int32]map[int32]interface{} // zoneID -> objectID
}
func NewMockZoneService() *MockZoneService {
return &MockZoneService{
rules: make(map[int32]map[string]interface{}),
objects: make(map[int32]map[int32]interface{}),
}
}
func (m *MockZoneService) GetZoneRule(zoneID int32, ruleName string) (interface{}, error) {
if rules, exists := m.rules[zoneID]; exists {
return rules[ruleName], nil
}
return true, nil // Default to enabled
}
func (m *MockZoneService) SpawnObjectInZone(zoneID int32, appearanceID int32, x, y, z, heading float32, name string, commands []string) (int32, error) {
objectID := int32(len(m.objects[zoneID]) + 1)
if m.objects[zoneID] == nil {
m.objects[zoneID] = make(map[int32]interface{})
}
m.objects[zoneID][objectID] = struct{}{}
return objectID, nil
}
func (m *MockZoneService) RemoveObjectFromZone(zoneID int32, objectID int32) error {
if objects, exists := m.objects[zoneID]; exists {
delete(objects, objectID)
}
return nil
}
func (m *MockZoneService) GetDistanceBetweenPoints(x1, y1, z1, x2, y2, z2 float32) float32 {
dx := x1 - x2
dy := y1 - y2
dz := z1 - z2
return float32(dx*dx + dy*dy + dz*dz) // Simplified distance calculation
}
// Test database setup
func setupTestDatabase(t *testing.T) *sql.DB {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
schema := `
CREATE TABLE loottable (
id INTEGER PRIMARY KEY,
name TEXT,
mincoin INTEGER DEFAULT 0,
maxcoin INTEGER DEFAULT 0,
maxlootitems INTEGER DEFAULT 6,
lootdrop_probability REAL DEFAULT 100.0,
coin_probability REAL DEFAULT 50.0
);
CREATE TABLE lootdrop (
loot_table_id INTEGER,
item_id INTEGER,
item_charges INTEGER DEFAULT 1,
equip_item INTEGER DEFAULT 0,
probability REAL DEFAULT 100.0,
no_drop_quest_completed_id INTEGER DEFAULT 0
);
CREATE TABLE spawn_loot (
spawn_id INTEGER,
loottable_id INTEGER
);
CREATE TABLE loot_global (
type TEXT,
loot_table INTEGER,
value1 INTEGER,
value2 INTEGER,
value3 INTEGER,
value4 INTEGER
);
`
if _, err := db.Exec(schema); err != nil {
t.Fatalf("Failed to create test schema: %v", err)
}
return db
}
// Insert test data
func insertTestLootData(t *testing.T, db *sql.DB) {
// Test loot table
_, err := db.Exec(`
INSERT INTO loottable (id, name, mincoin, maxcoin, maxlootitems, lootdrop_probability, coin_probability)
VALUES (1, 'Test Loot Table', 10, 50, 3, 100.0, 75.0)
`)
if err != nil {
t.Fatalf("Failed to insert test loot table: %v", err)
}
// Test loot drops
lootDrops := []struct {
tableID int32
itemID int32
charges int16
probability float32
}{
{1, 101, 1, 100.0}, // Always drops
{1, 102, 5, 50.0}, // 50% chance
{1, 103, 1, 25.0}, // 25% chance
}
for _, drop := range lootDrops {
_, err := db.Exec(`
INSERT INTO lootdrop (loot_table_id, item_id, item_charges, probability)
VALUES (?, ?, ?, ?)
`, drop.tableID, drop.itemID, drop.charges, drop.probability)
if err != nil {
t.Fatalf("Failed to insert loot drop: %v", err)
}
}
// Test spawn loot assignment
_, err = db.Exec(`
INSERT INTO spawn_loot (spawn_id, loottable_id)
VALUES (1001, 1)
`)
if err != nil {
t.Fatalf("Failed to insert spawn loot: %v", err)
}
// Test global loot
_, err = db.Exec(`
INSERT INTO loot_global (type, loot_table, value1, value2, value3, value4)
VALUES ('level', 1, 10, 20, 1, 0)
`)
if err != nil {
t.Fatalf("Failed to insert global loot: %v", err)
}
}
// Test Functions
func TestNewLootDatabase(t *testing.T) {
db := setupTestDatabase(t)
defer db.Close()
lootDB := NewLootDatabase(db)
if lootDB == nil {
t.Fatal("Expected non-nil LootDatabase")
}
if lootDB.db != db {
t.Error("Expected database connection to be set")
}
if len(lootDB.queries) == 0 {
t.Error("Expected queries to be prepared")
}
}
func TestLoadLootData(t *testing.T) {
db := setupTestDatabase(t)
defer db.Close()
insertTestLootData(t, db)
lootDB := NewLootDatabase(db)
err := lootDB.LoadAllLootData()
if err != nil {
t.Fatalf("Failed to load loot data: %v", err)
}
// Test loot table loaded
table := lootDB.GetLootTable(1)
if table == nil {
t.Fatal("Expected to find loot table 1")
}
if table.Name != "Test Loot Table" {
t.Errorf("Expected table name 'Test Loot Table', got '%s'", table.Name)
}
if len(table.Drops) != 3 {
t.Errorf("Expected 3 loot drops, got %d", len(table.Drops))
}
// Test spawn loot assignment
tables := lootDB.GetSpawnLootTables(1001)
if len(tables) != 1 || tables[0] != 1 {
t.Errorf("Expected spawn 1001 to have loot table 1, got %v", tables)
}
// Test global loot
globalLoot := lootDB.GetGlobalLootTables(15, 0, 0)
if len(globalLoot) != 1 {
t.Errorf("Expected 1 global loot entry for level 15, got %d", len(globalLoot))
}
}
func TestLootManager(t *testing.T) {
db := setupTestDatabase(t)
defer db.Close()
insertTestLootData(t, db)
lootDB := NewLootDatabase(db)
err := lootDB.LoadAllLootData()
if err != nil {
t.Fatalf("Failed to load loot data: %v", err)
}
// Create mock item master list
itemList := NewMockItemMasterList()
itemList.AddTestItem(101, "Test Sword", LootTierCommon)
itemList.AddTestItem(102, "Test Potion", LootTierCommon)
itemList.AddTestItem(103, "Test Shield", LootTierTreasured)
lootManager := NewLootManager(lootDB, itemList)
// Test loot generation
context := &LootContext{
PlayerLevel: 15,
PlayerRace: 1,
ZoneID: 100,
KillerID: 1,
GroupMembers: []uint32{1},
CompletedQuests: make(map[int32]bool),
LootMethod: GroupLootMethodFreeForAll,
}
result, err := lootManager.GenerateLoot(1001, context)
if err != nil {
t.Fatalf("Failed to generate loot: %v", err)
}
if result == nil {
t.Fatal("Expected non-nil loot result")
}
// Should have at least one item (100% drop chance for item 101)
items := result.GetItems()
if len(items) == 0 {
t.Error("Expected at least one item in loot result")
}
// Should have coins (75% probability)
coins := result.GetCoins()
t.Logf("Generated %d items and %d coins", len(items), coins)
}
func TestTreasureChestCreation(t *testing.T) {
db := setupTestDatabase(t)
defer db.Close()
lootDB := NewLootDatabase(db)
itemList := NewMockItemMasterList()
itemList.AddTestItem(101, "Test Item", LootTierLegendary) // High tier for ornate chest
lootManager := NewLootManager(lootDB, itemList)
// Create loot result
item := itemList.GetItem(101)
lootResult := &LootResult{
Items: []*items.Item{item},
Coins: 100,
}
// Create treasure chest
chest, err := lootManager.CreateTreasureChest(1001, 100, 10.0, 20.0, 30.0, 0.0, lootResult, []uint32{1, 2})
if err != nil {
t.Fatalf("Failed to create treasure chest: %v", err)
}
if chest.AppearanceID != ChestAppearanceOrnate {
t.Errorf("Expected ornate chest appearance %d for legendary item, got %d",
ChestAppearanceOrnate, chest.AppearanceID)
}
if len(chest.LootRights) != 2 {
t.Errorf("Expected 2 players with loot rights, got %d", len(chest.LootRights))
}
if !chest.HasLootRights(1) {
t.Error("Expected player 1 to have loot rights")
}
if chest.HasLootRights(3) {
t.Error("Expected player 3 to not have loot rights")
}
}
func TestChestService(t *testing.T) {
db := setupTestDatabase(t)
defer db.Close()
lootDB := NewLootDatabase(db)
itemList := NewMockItemMasterList()
itemList.AddTestItem(101, "Test Item", LootTierCommon)
lootManager := NewLootManager(lootDB, itemList)
// Create mock services
playerService := NewMockPlayerService()
zoneService := NewMockZoneService()
// Set up player near chest
playerService.SetPlayerPosition(1, 10.0, 20.0, 30.0, 0.0, 100)
playerService.SetInventorySpace(1, 10)
chestService := NewChestService(lootManager, playerService, zoneService)
// Create loot and chest
item := itemList.GetItem(101)
lootResult := &LootResult{
Items: []*items.Item{item},
Coins: 50,
}
chest, err := chestService.CreateTreasureChestFromLoot(1001, 100, 10.0, 20.0, 30.0, 0.0, lootResult, []uint32{1})
if err != nil {
t.Fatalf("Failed to create treasure chest: %v", err)
}
// Test viewing chest
result := chestService.HandleChestInteraction(chest.ID, 1, ChestInteractionView, 0)
if !result.Success {
t.Errorf("Expected successful chest view, got: %s", result.Message)
}
if len(result.Items) != 1 {
t.Errorf("Expected 1 item in view result, got %d", len(result.Items))
}
// Test looting item
result = chestService.HandleChestInteraction(chest.ID, 1, ChestInteractionLoot, item.Details.UniqueID)
if !result.Success {
t.Errorf("Expected successful item loot, got: %s", result.Message)
}
if len(result.Items) != 1 {
t.Errorf("Expected 1 looted item, got %d", len(result.Items))
}
// Chest should now be empty of items but still have coins
if lootManager.IsChestEmpty(chest.ID) {
t.Error("Expected chest to still have coins")
}
// Test looting all remaining (coins)
result = chestService.HandleChestInteraction(chest.ID, 1, ChestInteractionLootAll, 0)
if !result.Success {
t.Errorf("Expected successful loot all, got: %s", result.Message)
}
if result.Coins != 50 {
t.Errorf("Expected 50 coins looted, got %d", result.Coins)
}
// Chest should now be empty
if !lootManager.IsChestEmpty(chest.ID) {
t.Error("Expected chest to be empty after looting all")
}
}
func TestLootStatistics(t *testing.T) {
stats := NewLootStatistics()
// Create test loot result
item := &items.Item{
Details: items.ItemDetails{
ItemID: 101,
Tier: LootTierRare,
},
}
lootResult := &LootResult{
Items: []*items.Item{item},
Coins: 100,
}
// Record loot
stats.RecordLoot(1, lootResult)
stats.RecordChest()
current := stats.GetStatistics()
if current.TotalLoots != 1 {
t.Errorf("Expected 1 total loot, got %d", current.TotalLoots)
}
if current.TotalItems != 1 {
t.Errorf("Expected 1 total item, got %d", current.TotalItems)
}
if current.TotalCoins != 100 {
t.Errorf("Expected 100 total coins, got %d", current.TotalCoins)
}
if current.TreasureChests != 1 {
t.Errorf("Expected 1 treasure chest, got %d", current.TreasureChests)
}
if current.ItemsByTier[LootTierRare] != 1 {
t.Errorf("Expected 1 rare item, got %d", current.ItemsByTier[LootTierRare])
}
}
func TestChestAppearanceSelection(t *testing.T) {
testCases := []struct {
tier int8
expected int32
}{
{LootTierCommon, ChestAppearanceSmall},
{LootTierTreasured, ChestAppearanceTreasure},
{LootTierLegendary, ChestAppearanceOrnate},
{LootTierFabled, ChestAppearanceExquisite},
{LootTierMythical, ChestAppearanceExquisite},
}
for _, tc := range testCases {
appearance := GetChestAppearance(tc.tier)
if appearance.AppearanceID != tc.expected {
t.Errorf("For tier %d, expected appearance %d, got %d",
tc.tier, tc.expected, appearance.AppearanceID)
}
}
}
func TestLootValidation(t *testing.T) {
db := setupTestDatabase(t)
defer db.Close()
lootDB := NewLootDatabase(db)
itemList := NewMockItemMasterList()
lootManager := NewLootManager(lootDB, itemList)
playerService := NewMockPlayerService()
zoneService := NewMockZoneService()
chestService := NewChestService(lootManager, playerService, zoneService)
// Create a chest with loot rights for player 1
lootResult := &LootResult{Items: []*items.Item{}, Coins: 100}
chest, _ := lootManager.CreateTreasureChest(1001, 100, 10.0, 20.0, 30.0, 0.0, lootResult, []uint32{1})
// Test player without loot rights
result := chestService.HandleChestInteraction(chest.ID, 2, ChestInteractionView, 0)
if result.Success {
t.Error("Expected failure for player without loot rights")
}
if result.Result != ChestResultNoRights {
t.Errorf("Expected no rights result, got %d", result.Result)
}
// Test player in combat
playerService.SetPlayerPosition(1, 10.0, 20.0, 30.0, 0.0, 100)
playerService.combat[1] = true
result = chestService.HandleChestInteraction(chest.ID, 1, ChestInteractionView, 0)
if result.Success {
t.Error("Expected failure for player in combat")
}
if result.Result != ChestResultInCombat {
t.Errorf("Expected in combat result, got %d", result.Result)
}
// Test player too far away
playerService.combat[1] = false
playerService.SetPlayerPosition(1, 100.0, 100.0, 100.0, 0.0, 100)
result = chestService.HandleChestInteraction(chest.ID, 1, ChestInteractionView, 0)
if result.Success {
t.Error("Expected failure for player too far away")
}
if result.Result != ChestResultTooFar {
t.Errorf("Expected too far result, got %d", result.Result)
}
}
func TestCleanupExpiredChests(t *testing.T) {
db := setupTestDatabase(t)
defer db.Close()
lootDB := NewLootDatabase(db)
itemList := NewMockItemMasterList()
lootManager := NewLootManager(lootDB, itemList)
// Create an empty chest (should be cleaned up quickly)
emptyResult := &LootResult{Items: []*items.Item{}, Coins: 0}
emptyChest, _ := lootManager.CreateTreasureChest(1001, 100, 10.0, 20.0, 30.0, 0.0, emptyResult, []uint32{1})
// Modify the created time to make it expired
emptyChest.Created = time.Now().Add(-time.Duration(ChestDespawnTime+1) * time.Second)
// Run cleanup
lootManager.CleanupExpiredChests()
// Check that empty chest was removed
if lootManager.GetTreasureChest(emptyChest.ID) != nil {
t.Error("Expected expired empty chest to be cleaned up")
}
}
// Benchmark tests
func BenchmarkLootGeneration(b *testing.B) {
db := setupTestDatabase(b)
defer db.Close()
insertTestLootData(b, db)
lootDB := NewLootDatabase(db)
lootDB.LoadAllLootData()
itemList := NewMockItemMasterList()
itemList.AddTestItem(101, "Test Item", LootTierCommon)
itemList.AddTestItem(102, "Test Item 2", LootTierCommon)
itemList.AddTestItem(103, "Test Item 3", LootTierCommon)
lootManager := NewLootManager(lootDB, itemList)
context := &LootContext{
PlayerLevel: 15,
CompletedQuests: make(map[int32]bool),
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := lootManager.GenerateLoot(1001, context)
if err != nil {
b.Fatalf("Failed to generate loot: %v", err)
}
}
}
func BenchmarkChestInteraction(b *testing.B) {
db := setupTestDatabase(b)
defer db.Close()
lootDB := NewLootDatabase(db)
itemList := NewMockItemMasterList()
itemList.AddTestItem(101, "Test Item", LootTierCommon)
lootManager := NewLootManager(lootDB, itemList)
playerService := NewMockPlayerService()
zoneService := NewMockZoneService()
chestService := NewChestService(lootManager, playerService, zoneService)
// Set up player
playerService.SetPlayerPosition(1, 10.0, 20.0, 30.0, 0.0, 100)
playerService.SetInventorySpace(1, 100)
// Create chest with loot
item := itemList.GetItem(101)
lootResult := &LootResult{Items: []*items.Item{item}, Coins: 100}
chest, _ := chestService.CreateTreasureChestFromLoot(1001, 100, 10.0, 20.0, 30.0, 0.0, lootResult, []uint32{1})
b.ResetTimer()
for i := 0; i < b.N; i++ {
chestService.HandleChestInteraction(chest.ID, 1, ChestInteractionView, 0)
}
}

View File

@ -0,0 +1,483 @@
package loot
import (
"fmt"
"log"
"math/rand"
"sync"
"time"
"eq2emu/internal/items"
)
// LootManager handles all loot generation and management
type LootManager struct {
database *LootDatabase
itemMasterList items.MasterItemListService
statistics *LootStatistics
treasureChests map[int32]*TreasureChest // chest_id -> TreasureChest
chestIDCounter int32
random *rand.Rand
mutex sync.RWMutex
}
// NewLootManager creates a new loot manager
func NewLootManager(database *LootDatabase, itemMasterList items.MasterItemListService) *LootManager {
return &LootManager{
database: database,
itemMasterList: itemMasterList,
statistics: NewLootStatistics(),
treasureChests: make(map[int32]*TreasureChest),
chestIDCounter: 1,
random: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
// GenerateLoot generates loot for a spawn based on its loot table assignments
func (lm *LootManager) GenerateLoot(spawnID int32, context *LootContext) (*LootResult, error) {
log.Printf("%s Generating loot for spawn %d", LogPrefixGeneration, spawnID)
result := &LootResult{
Items: make([]*items.Item, 0),
Coins: 0,
}
// Get loot tables for this spawn
tableIDs := lm.database.GetSpawnLootTables(spawnID)
// Also check for global loot tables
globalLoot := lm.database.GetGlobalLootTables(context.PlayerLevel, context.PlayerRace, context.ZoneID)
for _, global := range globalLoot {
tableIDs = append(tableIDs, global.TableID)
}
if len(tableIDs) == 0 {
log.Printf("%s No loot tables found for spawn %d", LogPrefixGeneration, spawnID)
return result, nil
}
// Process each loot table
for _, tableID := range tableIDs {
if err := lm.processLootTable(tableID, context, result); err != nil {
log.Printf("%s Error processing loot table %d: %v", LogPrefixGeneration, tableID, err)
continue
}
}
// Record statistics
if len(tableIDs) > 0 {
lm.statistics.RecordLoot(tableIDs[0], result) // Use first table for stats
}
log.Printf("%s Generated %d items and %d coins for spawn %d",
LogPrefixGeneration, len(result.Items), result.Coins, spawnID)
return result, nil
}
// processLootTable processes a single loot table and adds results to the loot result
func (lm *LootManager) processLootTable(tableID int32, context *LootContext, result *LootResult) error {
table := lm.database.GetLootTable(tableID)
if table == nil {
return fmt.Errorf("loot table %d not found", tableID)
}
lm.mutex.Lock()
defer lm.mutex.Unlock()
// Check if loot should drop at all
if !lm.rollProbability(table.LootDropProbability) {
log.Printf("%s Loot table %d failed drop probability check", LogPrefixGeneration, tableID)
return nil
}
// Generate coins if probability succeeds
if lm.rollProbability(table.CoinProbability) {
coins := lm.generateCoins(table.MinCoin, table.MaxCoin)
result.AddCoins(coins)
log.Printf("%s Generated %d coins from table %d", LogPrefixGeneration, coins, tableID)
}
// Generate items
itemsGenerated := 0
maxItems := int(table.MaxLootItems)
if maxItems <= 0 {
maxItems = DefaultMaxLootItems
}
// Process each loot drop
for _, drop := range table.Drops {
// Check if we've hit the max item limit
if itemsGenerated >= maxItems {
break
}
// Check quest requirement
if drop.NoDropQuestCompletedID > 0 {
if !context.CompletedQuests[drop.NoDropQuestCompletedID] {
continue // Player hasn't completed required quest
}
}
// Roll probability for this drop
if !lm.rollProbability(drop.Probability) {
continue
}
// Get item template
itemTemplate := lm.itemMasterList.GetItem(drop.ItemID)
if itemTemplate == nil {
log.Printf("%s Item template %d not found for loot drop", LogPrefixGeneration, drop.ItemID)
continue
}
// Create item instance
item := items.NewItemFromTemplate(itemTemplate)
// Set charges if specified
if drop.ItemCharges > 0 {
item.Details.Count = drop.ItemCharges
}
// Mark as equipped if specified
if drop.EquipItem {
// This would be handled by the caller when distributing loot
// For now, we just note it in the item
}
result.AddItem(item)
itemsGenerated++
log.Printf("%s Generated item %d (%s) from table %d",
LogPrefixGeneration, drop.ItemID, item.Name, tableID)
}
return nil
}
// rollProbability rolls a probability check (0-100%)
func (lm *LootManager) rollProbability(probability float32) bool {
if probability <= 0 {
return false
}
if probability >= 100.0 {
return true
}
roll := lm.random.Float32() * 100.0
return roll <= probability
}
// generateCoins generates a random coin amount between min and max
func (lm *LootManager) generateCoins(minCoin, maxCoin int32) int32 {
if minCoin >= maxCoin {
return minCoin
}
return minCoin + lm.random.Int31n(maxCoin-minCoin+1)
}
// CreateTreasureChest creates a treasure chest for loot
func (lm *LootManager) CreateTreasureChest(spawnID int32, zoneID int32, x, y, z, heading float32,
lootResult *LootResult, lootRights []uint32) (*TreasureChest, error) {
lm.mutex.Lock()
defer lm.mutex.Unlock()
// Generate unique chest ID
chestID := lm.chestIDCounter
lm.chestIDCounter++
// Determine chest appearance based on highest item tier
highestTier := lm.getHighestItemTier(lootResult.GetItems())
appearance := GetChestAppearance(highestTier)
chest := &TreasureChest{
ID: chestID,
SpawnID: spawnID,
ZoneID: zoneID,
X: x,
Y: y,
Z: z,
Heading: heading,
AppearanceID: appearance.AppearanceID,
LootResult: lootResult,
Created: time.Now(),
LootRights: make([]uint32, len(lootRights)),
IsDisarmable: false, // TODO: Implement trap system
IsLocked: false, // TODO: Implement lock system
}
// Copy loot rights
copy(chest.LootRights, lootRights)
// Store chest
lm.treasureChests[chestID] = chest
// Record statistics
lm.statistics.RecordChest()
log.Printf("%s Created treasure chest %d (%s) at (%.2f, %.2f, %.2f) with %d items and %d coins",
LogPrefixChest, chestID, appearance.Name, x, y, z,
len(lootResult.GetItems()), lootResult.GetCoins())
return chest, nil
}
// getHighestItemTier finds the highest tier among items
func (lm *LootManager) getHighestItemTier(items []*items.Item) int8 {
var highest int8 = LootTierCommon
for _, item := range items {
if item.Details.Tier > highest {
highest = item.Details.Tier
}
}
return highest
}
// GetTreasureChest returns a treasure chest by ID
func (lm *LootManager) GetTreasureChest(chestID int32) *TreasureChest {
lm.mutex.RLock()
defer lm.mutex.RUnlock()
return lm.treasureChests[chestID]
}
// RemoveTreasureChest removes a treasure chest
func (lm *LootManager) RemoveTreasureChest(chestID int32) {
lm.mutex.Lock()
defer lm.mutex.Unlock()
delete(lm.treasureChests, chestID)
log.Printf("%s Removed treasure chest %d", LogPrefixChest, chestID)
}
// LootChestItem removes a specific item from a chest
func (lm *LootManager) LootChestItem(chestID int32, playerID uint32, itemUniqueID int64) (*items.Item, error) {
lm.mutex.Lock()
defer lm.mutex.Unlock()
chest := lm.treasureChests[chestID]
if chest == nil {
return nil, fmt.Errorf("treasure chest %d not found", chestID)
}
// Check loot rights
if !chest.HasLootRights(playerID) {
return nil, fmt.Errorf("player %d has no loot rights for chest %d", playerID, chestID)
}
// Find and remove the item
lootItems := chest.LootResult.GetItems()
for i, item := range lootItems {
if item.Details.UniqueID == itemUniqueID {
// Remove item from slice
chest.LootResult.mutex.Lock()
chest.LootResult.Items = append(chest.LootResult.Items[:i], chest.LootResult.Items[i+1:]...)
chest.LootResult.mutex.Unlock()
log.Printf("%s Player %d looted item %d (%s) from chest %d",
LogPrefixChest, playerID, item.Details.ItemID, item.Name, chestID)
return item, nil
}
}
return nil, fmt.Errorf("item %d not found in chest %d", itemUniqueID, chestID)
}
// LootChestCoins removes coins from a chest
func (lm *LootManager) LootChestCoins(chestID int32, playerID uint32) (int32, error) {
lm.mutex.Lock()
defer lm.mutex.Unlock()
chest := lm.treasureChests[chestID]
if chest == nil {
return 0, fmt.Errorf("treasure chest %d not found", chestID)
}
// Check loot rights
if !chest.HasLootRights(playerID) {
return 0, fmt.Errorf("player %d has no loot rights for chest %d", playerID, chestID)
}
coins := chest.LootResult.GetCoins()
if coins <= 0 {
return 0, nil
}
// Remove coins from chest
chest.LootResult.mutex.Lock()
chest.LootResult.Coins = 0
chest.LootResult.mutex.Unlock()
log.Printf("%s Player %d looted %d coins from chest %d",
LogPrefixChest, playerID, coins, chestID)
return coins, nil
}
// LootChestAll removes all items and coins from a chest
func (lm *LootManager) LootChestAll(chestID int32, playerID uint32) (*LootResult, error) {
lm.mutex.Lock()
defer lm.mutex.Unlock()
chest := lm.treasureChests[chestID]
if chest == nil {
return nil, fmt.Errorf("treasure chest %d not found", chestID)
}
// Check loot rights
if !chest.HasLootRights(playerID) {
return nil, fmt.Errorf("player %d has no loot rights for chest %d", playerID, chestID)
}
// Get all loot
result := &LootResult{
Items: chest.LootResult.GetItems(),
Coins: chest.LootResult.GetCoins(),
}
// Clear chest loot
chest.LootResult.mutex.Lock()
chest.LootResult.Items = make([]*items.Item, 0)
chest.LootResult.Coins = 0
chest.LootResult.mutex.Unlock()
log.Printf("%s Player %d looted all (%d items, %d coins) from chest %d",
LogPrefixChest, playerID, len(result.Items), result.Coins, chestID)
return result, nil
}
// IsChestEmpty checks if a chest has no loot
func (lm *LootManager) IsChestEmpty(chestID int32) bool {
lm.mutex.RLock()
defer lm.mutex.RUnlock()
chest := lm.treasureChests[chestID]
if chest == nil {
return true
}
return chest.LootResult.IsEmpty()
}
// CleanupExpiredChests removes chests that have been around too long
func (lm *LootManager) CleanupExpiredChests() {
lm.mutex.Lock()
defer lm.mutex.Unlock()
now := time.Now()
var expired []int32
for chestID, chest := range lm.treasureChests {
age := now.Sub(chest.Created).Seconds()
// Remove empty chests after ChestDespawnTime
if chest.LootResult.IsEmpty() && age > ChestDespawnTime {
expired = append(expired, chestID)
}
// Force remove all chests after ChestCleanupTime
if age > ChestCleanupTime {
expired = append(expired, chestID)
}
}
for _, chestID := range expired {
delete(lm.treasureChests, chestID)
log.Printf("%s Cleaned up expired chest %d", LogPrefixChest, chestID)
}
if len(expired) > 0 {
log.Printf("%s Cleaned up %d expired chests", LogPrefixChest, len(expired))
}
}
// GetZoneChests returns all chests in a specific zone
func (lm *LootManager) GetZoneChests(zoneID int32) []*TreasureChest {
lm.mutex.RLock()
defer lm.mutex.RUnlock()
var chests []*TreasureChest
for _, chest := range lm.treasureChests {
if chest.ZoneID == zoneID {
chests = append(chests, chest)
}
}
return chests
}
// GetPlayerChests returns all chests a player has loot rights to
func (lm *LootManager) GetPlayerChests(playerID uint32) []*TreasureChest {
lm.mutex.RLock()
defer lm.mutex.RUnlock()
var chests []*TreasureChest
for _, chest := range lm.treasureChests {
if chest.HasLootRights(playerID) {
chests = append(chests, chest)
}
}
return chests
}
// GetStatistics returns loot generation statistics
func (lm *LootManager) GetStatistics() LootStatistics {
return lm.statistics.GetStatistics()
}
// ReloadLootData reloads loot data from the database
func (lm *LootManager) ReloadLootData() error {
log.Printf("%s Reloading loot data...", LogPrefixLoot)
return lm.database.ReloadLootData()
}
// AddLootTable adds a new loot table
func (lm *LootManager) AddLootTable(table *LootTable) error {
log.Printf("%s Adding loot table %d (%s)", LogPrefixLoot, table.ID, table.Name)
return lm.database.AddLootTable(table)
}
// UpdateLootTable updates an existing loot table
func (lm *LootManager) UpdateLootTable(table *LootTable) error {
log.Printf("%s Updating loot table %d (%s)", LogPrefixLoot, table.ID, table.Name)
return lm.database.UpdateLootTable(table)
}
// DeleteLootTable removes a loot table
func (lm *LootManager) DeleteLootTable(tableID int32) error {
log.Printf("%s Deleting loot table %d", LogPrefixLoot, tableID)
return lm.database.DeleteLootTable(tableID)
}
// AssignSpawnLoot assigns a loot table to a spawn
func (lm *LootManager) AssignSpawnLoot(spawnID, tableID int32) error {
log.Printf("%s Assigning loot table %d to spawn %d", LogPrefixLoot, tableID, spawnID)
return lm.database.AddSpawnLoot(spawnID, tableID)
}
// RemoveSpawnLoot removes loot table assignments from a spawn
func (lm *LootManager) RemoveSpawnLoot(spawnID int32) error {
log.Printf("%s Removing loot assignments from spawn %d", LogPrefixLoot, spawnID)
return lm.database.DeleteSpawnLoot(spawnID)
}
// StartCleanupTimer starts a background timer to clean up expired chests
func (lm *LootManager) StartCleanupTimer() {
go func() {
ticker := time.NewTicker(5 * time.Minute) // Clean up every 5 minutes
defer ticker.Stop()
for range ticker.C {
lm.CleanupExpiredChests()
}
}()
log.Printf("%s Started chest cleanup timer", LogPrefixLoot)
}

View File

@ -0,0 +1,464 @@
package loot
import (
"fmt"
"log"
"eq2emu/internal/items"
)
// PacketBuilder interface for building loot-related packets
type PacketBuilder interface {
BuildUpdateLootPacket(chest *TreasureChest, playerID uint32, clientVersion int32) ([]byte, error)
BuildLootItemPacket(item *items.Item, playerID uint32, clientVersion int32) ([]byte, error)
BuildStoppedLootingPacket(chestID int32, playerID uint32, clientVersion int32) ([]byte, error)
BuildLootResponsePacket(result *ChestInteractionResult, clientVersion int32) ([]byte, error)
}
// LootPacketBuilder builds loot-related packets for client communication
type LootPacketBuilder struct {
itemPacketBuilder ItemPacketBuilder
}
// ItemPacketBuilder interface for building item-related packet data
type ItemPacketBuilder interface {
BuildItemData(item *items.Item, clientVersion int32) ([]byte, error)
GetItemAppearanceData(item *items.Item) (int32, int16, int16, int16, int16, int16, int16)
}
// NewLootPacketBuilder creates a new loot packet builder
func NewLootPacketBuilder(itemPacketBuilder ItemPacketBuilder) *LootPacketBuilder {
return &LootPacketBuilder{
itemPacketBuilder: itemPacketBuilder,
}
}
// BuildUpdateLootPacket builds an UpdateLoot packet to show chest contents to a player
func (lpb *LootPacketBuilder) BuildUpdateLootPacket(chest *TreasureChest, playerID uint32, clientVersion int32) ([]byte, error) {
log.Printf("%s Building UpdateLoot packet for chest %d, player %d, version %d",
LogPrefixLoot, chest.ID, playerID, clientVersion)
// Start with base packet structure
packet := &LootPacketData{
PacketType: "UpdateLoot",
ChestID: chest.ID,
SpawnID: chest.SpawnID,
PlayerID: playerID,
ClientVersion: clientVersion,
}
// Add loot items
lootItems := chest.LootResult.GetItems()
packet.ItemCount = int16(len(lootItems))
packet.Items = make([]*LootItemData, len(lootItems))
for i, item := range lootItems {
itemData, err := lpb.buildLootItemData(item, clientVersion)
if err != nil {
log.Printf("%s Failed to build item data for item %d: %v", LogPrefixLoot, item.Details.ItemID, err)
continue
}
packet.Items[i] = itemData
}
// Add coin information
packet.Coins = chest.LootResult.GetCoins()
// Build packet based on client version
return lpb.buildVersionSpecificLootPacket(packet)
}
// buildLootItemData builds loot item data for a specific item
func (lpb *LootPacketBuilder) buildLootItemData(item *items.Item, clientVersion int32) (*LootItemData, error) {
// Get item appearance data
appearanceID, red, green, blue, highlightRed, highlightGreen, highlightBlue :=
lpb.itemPacketBuilder.GetItemAppearanceData(item)
return &LootItemData{
ItemID: item.Details.ItemID,
UniqueID: item.Details.UniqueID,
Name: item.Name,
Count: item.Details.Count,
Tier: item.Details.Tier,
Icon: item.Details.Icon,
AppearanceID: appearanceID,
Red: red,
Green: green,
Blue: blue,
HighlightRed: highlightRed,
HighlightGreen: highlightGreen,
HighlightBlue: highlightBlue,
ItemType: item.GenericInfo.ItemType,
NoTrade: (item.GenericInfo.ItemFlags & uint32(LootFlagNoTrade)) != 0,
Heirloom: (item.GenericInfo.ItemFlags & uint32(LootFlagHeirloom)) != 0,
Lore: (item.GenericInfo.ItemFlags & uint32(LootFlagLore)) != 0,
}, nil
}
// buildVersionSpecificLootPacket builds the actual packet bytes based on client version
func (lpb *LootPacketBuilder) buildVersionSpecificLootPacket(packet *LootPacketData) ([]byte, error) {
switch {
case packet.ClientVersion >= 60114:
return lpb.buildLootPacketV60114(packet)
case packet.ClientVersion >= 1193:
return lpb.buildLootPacketV1193(packet)
case packet.ClientVersion >= 546:
return lpb.buildLootPacketV546(packet)
case packet.ClientVersion >= 373:
return lpb.buildLootPacketV373(packet)
default:
return lpb.buildLootPacketV1(packet)
}
}
// buildLootPacketV60114 builds loot packet for client version 60114+
func (lpb *LootPacketBuilder) buildLootPacketV60114(packet *LootPacketData) ([]byte, error) {
// This is the most recent packet format with all features
buffer := NewPacketBuffer()
// Packet header
buffer.WriteInt32(packet.ChestID)
buffer.WriteInt32(packet.SpawnID)
buffer.WriteInt16(packet.ItemCount)
buffer.WriteInt32(packet.Coins)
// Loot options
buffer.WriteInt8(1) // loot_all_enabled
buffer.WriteInt8(1) // auto_loot_enabled
buffer.WriteInt8(0) // loot_timeout (0 = no timeout)
// Item array
for _, item := range packet.Items {
if item == nil {
continue
}
buffer.WriteInt32(item.ItemID)
buffer.WriteInt64(item.UniqueID)
buffer.WriteString(item.Name)
buffer.WriteInt16(item.Count)
buffer.WriteInt8(item.Tier)
buffer.WriteInt16(item.Icon)
buffer.WriteInt32(item.AppearanceID)
buffer.WriteInt16(item.Red)
buffer.WriteInt16(item.Green)
buffer.WriteInt16(item.Blue)
buffer.WriteInt16(item.HighlightRed)
buffer.WriteInt16(item.HighlightGreen)
buffer.WriteInt16(item.HighlightBlue)
buffer.WriteInt8(item.ItemType)
buffer.WriteBool(item.NoTrade)
buffer.WriteBool(item.Heirloom)
buffer.WriteBool(item.Lore)
// Extended item data for newer clients
buffer.WriteInt32(0) // adornment_slot0
buffer.WriteInt32(0) // adornment_slot1
buffer.WriteInt32(0) // adornment_slot2
}
return buffer.GetBytes(), nil
}
// buildLootPacketV1193 builds loot packet for client version 1193+
func (lpb *LootPacketBuilder) buildLootPacketV1193(packet *LootPacketData) ([]byte, error) {
buffer := NewPacketBuffer()
buffer.WriteInt32(packet.ChestID)
buffer.WriteInt32(packet.SpawnID)
buffer.WriteInt16(packet.ItemCount)
buffer.WriteInt32(packet.Coins)
buffer.WriteInt8(1) // loot_all_enabled
for _, item := range packet.Items {
if item == nil {
continue
}
buffer.WriteInt32(item.ItemID)
buffer.WriteInt64(item.UniqueID)
buffer.WriteString(item.Name)
buffer.WriteInt16(item.Count)
buffer.WriteInt8(item.Tier)
buffer.WriteInt16(item.Icon)
buffer.WriteInt32(item.AppearanceID)
buffer.WriteInt16(item.Red)
buffer.WriteInt16(item.Green)
buffer.WriteInt16(item.Blue)
buffer.WriteInt8(item.ItemType)
buffer.WriteBool(item.NoTrade)
buffer.WriteBool(item.Heirloom)
}
return buffer.GetBytes(), nil
}
// buildLootPacketV546 builds loot packet for client version 546+
func (lpb *LootPacketBuilder) buildLootPacketV546(packet *LootPacketData) ([]byte, error) {
buffer := NewPacketBuffer()
buffer.WriteInt32(packet.ChestID)
buffer.WriteInt32(packet.SpawnID)
buffer.WriteInt16(packet.ItemCount)
buffer.WriteInt32(packet.Coins)
for _, item := range packet.Items {
if item == nil {
continue
}
buffer.WriteInt32(item.ItemID)
buffer.WriteInt64(item.UniqueID)
buffer.WriteString(item.Name)
buffer.WriteInt16(item.Count)
buffer.WriteInt8(item.Tier)
buffer.WriteInt16(item.Icon)
buffer.WriteInt8(item.ItemType)
buffer.WriteBool(item.NoTrade)
}
return buffer.GetBytes(), nil
}
// buildLootPacketV373 builds loot packet for client version 373+
func (lpb *LootPacketBuilder) buildLootPacketV373(packet *LootPacketData) ([]byte, error) {
buffer := NewPacketBuffer()
buffer.WriteInt32(packet.ChestID)
buffer.WriteInt16(packet.ItemCount)
buffer.WriteInt32(packet.Coins)
for _, item := range packet.Items {
if item == nil {
continue
}
buffer.WriteInt32(item.ItemID)
buffer.WriteString(item.Name)
buffer.WriteInt16(item.Count)
buffer.WriteInt16(item.Icon)
buffer.WriteInt8(item.ItemType)
}
return buffer.GetBytes(), nil
}
// buildLootPacketV1 builds loot packet for client version 1 (oldest)
func (lpb *LootPacketBuilder) buildLootPacketV1(packet *LootPacketData) ([]byte, error) {
buffer := NewPacketBuffer()
buffer.WriteInt32(packet.ChestID)
buffer.WriteInt16(packet.ItemCount)
for _, item := range packet.Items {
if item == nil {
continue
}
buffer.WriteInt32(item.ItemID)
buffer.WriteString(item.Name)
buffer.WriteInt16(item.Count)
}
return buffer.GetBytes(), nil
}
// BuildLootItemPacket builds a packet for when a player loots a specific item
func (lpb *LootPacketBuilder) BuildLootItemPacket(item *items.Item, playerID uint32, clientVersion int32) ([]byte, error) {
log.Printf("%s Building LootItem packet for item %d, player %d", LogPrefixLoot, item.Details.ItemID, playerID)
buffer := NewPacketBuffer()
// Basic loot item response
buffer.WriteInt32(item.Details.ItemID)
buffer.WriteInt64(item.Details.UniqueID)
buffer.WriteString(item.Name)
buffer.WriteInt16(item.Details.Count)
buffer.WriteInt8(1) // success flag
return buffer.GetBytes(), nil
}
// BuildStoppedLootingPacket builds a packet when player stops looting
func (lpb *LootPacketBuilder) BuildStoppedLootingPacket(chestID int32, playerID uint32, clientVersion int32) ([]byte, error) {
log.Printf("%s Building StoppedLooting packet for chest %d, player %d", LogPrefixLoot, chestID, playerID)
buffer := NewPacketBuffer()
buffer.WriteInt32(chestID)
return buffer.GetBytes(), nil
}
// BuildLootResponsePacket builds a response packet for chest interactions
func (lpb *LootPacketBuilder) BuildLootResponsePacket(result *ChestInteractionResult, clientVersion int32) ([]byte, error) {
buffer := NewPacketBuffer()
// Result code and message
buffer.WriteInt8(result.Result)
buffer.WriteBool(result.Success)
buffer.WriteString(result.Message)
// Items received
buffer.WriteInt16(int16(len(result.Items)))
for _, item := range result.Items {
buffer.WriteInt32(item.Details.ItemID)
buffer.WriteString(item.Name)
buffer.WriteInt16(item.Details.Count)
}
// Coins received
buffer.WriteInt32(result.Coins)
// Experience gained
buffer.WriteInt32(result.Experience)
// Status flags
buffer.WriteBool(result.ChestEmpty)
buffer.WriteBool(result.ChestClosed)
return buffer.GetBytes(), nil
}
// LootPacketData represents the data structure for loot packets
type LootPacketData struct {
PacketType string
ChestID int32
SpawnID int32
PlayerID uint32
ClientVersion int32
ItemCount int16
Items []*LootItemData
Coins int32
}
// LootItemData represents an item in a loot packet
type LootItemData struct {
ItemID int32
UniqueID int64
Name string
Count int16
Tier int8
Icon int16
AppearanceID int32
Red int16
Green int16
Blue int16
HighlightRed int16
HighlightGreen int16
HighlightBlue int16
ItemType int8
NoTrade bool
Heirloom bool
Lore bool
}
// PacketBuffer is a simple buffer for building packet data
type PacketBuffer struct {
data []byte
}
// NewPacketBuffer creates a new packet buffer
func NewPacketBuffer() *PacketBuffer {
return &PacketBuffer{
data: make([]byte, 0, 1024),
}
}
// WriteInt8 writes an 8-bit integer
func (pb *PacketBuffer) WriteInt8(value int8) {
pb.data = append(pb.data, byte(value))
}
// WriteInt16 writes a 16-bit integer
func (pb *PacketBuffer) WriteInt16(value int16) {
pb.data = append(pb.data, byte(value), byte(value>>8))
}
// WriteInt32 writes a 32-bit integer
func (pb *PacketBuffer) WriteInt32(value int32) {
pb.data = append(pb.data,
byte(value), byte(value>>8), byte(value>>16), byte(value>>24))
}
// WriteInt64 writes a 64-bit integer
func (pb *PacketBuffer) WriteInt64(value int64) {
pb.data = append(pb.data,
byte(value), byte(value>>8), byte(value>>16), byte(value>>24),
byte(value>>32), byte(value>>40), byte(value>>48), byte(value>>56))
}
// WriteBool writes a boolean as a single byte
func (pb *PacketBuffer) WriteBool(value bool) {
if value {
pb.data = append(pb.data, 1)
} else {
pb.data = append(pb.data, 0)
}
}
// WriteString writes a null-terminated string
func (pb *PacketBuffer) WriteString(value string) {
pb.data = append(pb.data, []byte(value)...)
pb.data = append(pb.data, 0) // null terminator
}
// GetBytes returns the current buffer data
func (pb *PacketBuffer) GetBytes() []byte {
return pb.data
}
// LootPacketService provides high-level packet building services
type LootPacketService struct {
packetBuilder *LootPacketBuilder
clientService ClientService
}
// ClientService interface for client-related operations
type ClientService interface {
GetClientVersion(playerID uint32) int32
SendPacketToPlayer(playerID uint32, packetType string, data []byte) error
}
// NewLootPacketService creates a new loot packet service
func NewLootPacketService(packetBuilder *LootPacketBuilder, clientService ClientService) *LootPacketService {
return &LootPacketService{
packetBuilder: packetBuilder,
clientService: clientService,
}
}
// SendLootUpdate sends a loot update packet to a player
func (lps *LootPacketService) SendLootUpdate(chest *TreasureChest, playerID uint32) error {
clientVersion := lps.clientService.GetClientVersion(playerID)
packet, err := lps.packetBuilder.BuildUpdateLootPacket(chest, playerID, clientVersion)
if err != nil {
return fmt.Errorf("failed to build loot update packet: %v", err)
}
return lps.clientService.SendPacketToPlayer(playerID, "UpdateLoot", packet)
}
// SendLootResponse sends a loot interaction response to a player
func (lps *LootPacketService) SendLootResponse(result *ChestInteractionResult, playerID uint32) error {
clientVersion := lps.clientService.GetClientVersion(playerID)
packet, err := lps.packetBuilder.BuildLootResponsePacket(result, clientVersion)
if err != nil {
return fmt.Errorf("failed to build loot response packet: %v", err)
}
return lps.clientService.SendPacketToPlayer(playerID, "LootResponse", packet)
}
// SendStoppedLooting sends a stopped looting packet to a player
func (lps *LootPacketService) SendStoppedLooting(chestID int32, playerID uint32) error {
clientVersion := lps.clientService.GetClientVersion(playerID)
packet, err := lps.packetBuilder.BuildStoppedLootingPacket(chestID, playerID, clientVersion)
if err != nil {
return fmt.Errorf("failed to build stopped looting packet: %v", err)
}
return lps.clientService.SendPacketToPlayer(playerID, "StoppedLooting", packet)
}

View File

@ -0,0 +1,321 @@
package loot
import (
"sync"
"time"
"eq2emu/internal/items"
)
// LootTable represents a complete loot table with its drops
type LootTable struct {
ID int32 `json:"id"`
Name string `json:"name"`
MinCoin int32 `json:"min_coin"`
MaxCoin int32 `json:"max_coin"`
MaxLootItems int16 `json:"max_loot_items"`
LootDropProbability float32 `json:"loot_drop_probability"`
CoinProbability float32 `json:"coin_probability"`
Drops []*LootDrop `json:"drops"`
mutex sync.RWMutex
}
// LootDrop represents an individual item that can drop from a loot table
type LootDrop struct {
LootTableID int32 `json:"loot_table_id"`
ItemID int32 `json:"item_id"`
ItemCharges int16 `json:"item_charges"`
EquipItem bool `json:"equip_item"`
Probability float32 `json:"probability"`
NoDropQuestCompletedID int32 `json:"no_drop_quest_completed_id"`
}
// GlobalLoot represents global loot configuration based on level, race, or zone
type GlobalLoot struct {
Type GlobalLootType `json:"type"`
MinLevel int8 `json:"min_level"`
MaxLevel int8 `json:"max_level"`
Race int16 `json:"race"`
ZoneID int32 `json:"zone_id"`
TableID int32 `json:"table_id"`
LootTier int32 `json:"loot_tier"`
}
// GlobalLootType represents the type of global loot
type GlobalLootType int8
const (
GlobalLootTypeLevel GlobalLootType = iota
GlobalLootTypeRace
GlobalLootTypeZone
)
// String returns the string representation of GlobalLootType
func (t GlobalLootType) String() string {
switch t {
case GlobalLootTypeLevel:
return "level"
case GlobalLootTypeRace:
return "race"
case GlobalLootTypeZone:
return "zone"
default:
return "unknown"
}
}
// LootResult represents the result of loot generation
type LootResult struct {
Items []*items.Item `json:"items"`
Coins int32 `json:"coins"`
mutex sync.RWMutex
}
// AddItem adds an item to the loot result (thread-safe)
func (lr *LootResult) AddItem(item *items.Item) {
lr.mutex.Lock()
defer lr.mutex.Unlock()
lr.Items = append(lr.Items, item)
}
// AddCoins adds coins to the loot result (thread-safe)
func (lr *LootResult) AddCoins(coins int32) {
lr.mutex.Lock()
defer lr.mutex.Unlock()
lr.Coins += coins
}
// GetItems returns a copy of the items slice (thread-safe)
func (lr *LootResult) GetItems() []*items.Item {
lr.mutex.RLock()
defer lr.mutex.RUnlock()
result := make([]*items.Item, len(lr.Items))
copy(result, lr.Items)
return result
}
// GetCoins returns the coin amount (thread-safe)
func (lr *LootResult) GetCoins() int32 {
lr.mutex.RLock()
defer lr.mutex.RUnlock()
return lr.Coins
}
// IsEmpty returns true if the loot result has no items or coins
func (lr *LootResult) IsEmpty() bool {
lr.mutex.RLock()
defer lr.mutex.RUnlock()
return len(lr.Items) == 0 && lr.Coins == 0
}
// TreasureChest represents a treasure chest spawn containing loot
type TreasureChest struct {
ID int32 `json:"id"`
SpawnID int32 `json:"spawn_id"`
ZoneID int32 `json:"zone_id"`
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
Heading float32 `json:"heading"`
AppearanceID int32 `json:"appearance_id"`
LootResult *LootResult `json:"loot_result"`
Created time.Time `json:"created"`
LootRights []uint32 `json:"loot_rights"` // Player IDs with loot rights
IsDisarmable bool `json:"is_disarmable"` // Can be disarmed
IsLocked bool `json:"is_locked"` // Requires key or lockpicking
DisarmDifficulty int16 `json:"disarm_difficulty"` // Difficulty for disarming
LockpickDifficulty int16 `json:"lockpick_difficulty"` // Difficulty for lockpicking
mutex sync.RWMutex
}
// HasLootRights checks if a player has rights to loot this chest
func (tc *TreasureChest) HasLootRights(playerID uint32) bool {
tc.mutex.RLock()
defer tc.mutex.RUnlock()
// If no specific loot rights, anyone can loot
if len(tc.LootRights) == 0 {
return true
}
for _, id := range tc.LootRights {
if id == playerID {
return true
}
}
return false
}
// AddLootRights adds a player to the loot rights list
func (tc *TreasureChest) AddLootRights(playerID uint32) {
tc.mutex.Lock()
defer tc.mutex.Unlock()
// Check if already has rights
for _, id := range tc.LootRights {
if id == playerID {
return
}
}
tc.LootRights = append(tc.LootRights, playerID)
}
// ChestAppearance represents different chest appearances based on loot tier
type ChestAppearance struct {
AppearanceID int32 `json:"appearance_id"`
Name string `json:"name"`
MinTier int8 `json:"min_tier"`
MaxTier int8 `json:"max_tier"`
}
// Predefined chest appearances based on C++ implementation
var (
SmallChest = &ChestAppearance{AppearanceID: 4034, Name: "Small Chest", MinTier: 1, MaxTier: 2}
TreasureChest = &ChestAppearance{AppearanceID: 5864, Name: "Treasure Chest", MinTier: 3, MaxTier: 4}
OrnateChest = &ChestAppearance{AppearanceID: 5865, Name: "Ornate Chest", MinTier: 5, MaxTier: 6}
ExquisiteChest = &ChestAppearance{AppearanceID: 4015, Name: "Exquisite Chest", MinTier: 7, MaxTier: 10}
)
// GetChestAppearance returns the appropriate chest appearance based on loot tier
func GetChestAppearance(highestTier int8) *ChestAppearance {
if highestTier >= ExquisiteChest.MinTier {
return ExquisiteChest
}
if highestTier >= OrnateChest.MinTier {
return OrnateChest
}
if highestTier >= TreasureChest.MinTier {
return TreasureChest
}
return SmallChest
}
// LootContext provides context for loot generation
type LootContext struct {
PlayerLevel int16 `json:"player_level"`
PlayerRace int16 `json:"player_race"`
ZoneID int32 `json:"zone_id"`
KillerID uint32 `json:"killer_id"`
GroupMembers []uint32 `json:"group_members"`
CompletedQuests map[int32]bool `json:"completed_quests"`
LootMethod GroupLootMethod `json:"loot_method"`
}
// GroupLootMethod represents different group loot distribution methods
type GroupLootMethod int8
const (
GroupLootMethodFreeForAll GroupLootMethod = iota
GroupLootMethodRoundRobin
GroupLootMethodMasterLooter
GroupLootMethodNeed
GroupLootMethodLotto
)
// String returns the string representation of GroupLootMethod
func (glm GroupLootMethod) String() string {
switch glm {
case GroupLootMethodFreeForAll:
return "free_for_all"
case GroupLootMethodRoundRobin:
return "round_robin"
case GroupLootMethodMasterLooter:
return "master_looter"
case GroupLootMethodNeed:
return "need_greed"
case GroupLootMethodLotto:
return "lotto"
default:
return "unknown"
}
}
// LootEntry represents a complete loot entry with all associated data
type LootEntry struct {
SpawnID int32 `json:"spawn_id"`
LootTableID int32 `json:"loot_table_id"`
TableName string `json:"table_name"`
Priority int16 `json:"priority"`
}
// LootStatistics tracks loot generation statistics
type LootStatistics struct {
TotalLoots int64 `json:"total_loots"`
TotalItems int64 `json:"total_items"`
TotalCoins int64 `json:"total_coins"`
TreasureChests int64 `json:"treasure_chests"`
ItemsByTier map[int8]int64 `json:"items_by_tier"`
LootsByTable map[int32]int64 `json:"loots_by_table"`
AverageItemsPerLoot float32 `json:"average_items_per_loot"`
AverageCoinsPerLoot float32 `json:"average_coins_per_loot"`
mutex sync.RWMutex
}
// NewLootStatistics creates a new loot statistics tracker
func NewLootStatistics() *LootStatistics {
return &LootStatistics{
ItemsByTier: make(map[int8]int64),
LootsByTable: make(map[int32]int64),
}
}
// RecordLoot records statistics for a loot generation
func (ls *LootStatistics) RecordLoot(tableID int32, result *LootResult) {
ls.mutex.Lock()
defer ls.mutex.Unlock()
ls.TotalLoots++
ls.LootsByTable[tableID]++
items := result.GetItems()
ls.TotalItems += int64(len(items))
ls.TotalCoins += int64(result.GetCoins())
// Track items by tier
for _, item := range items {
ls.ItemsByTier[item.Details.Tier]++
}
// Update averages
if ls.TotalLoots > 0 {
ls.AverageItemsPerLoot = float32(ls.TotalItems) / float32(ls.TotalLoots)
ls.AverageCoinsPerLoot = float32(ls.TotalCoins) / float32(ls.TotalLoots)
}
}
// RecordChest records a treasure chest creation
func (ls *LootStatistics) RecordChest() {
ls.mutex.Lock()
defer ls.mutex.Unlock()
ls.TreasureChests++
}
// GetStatistics returns a copy of the current statistics
func (ls *LootStatistics) GetStatistics() LootStatistics {
ls.mutex.RLock()
defer ls.mutex.RUnlock()
// Create deep copy
copy := LootStatistics{
TotalLoots: ls.TotalLoots,
TotalItems: ls.TotalItems,
TotalCoins: ls.TotalCoins,
TreasureChests: ls.TreasureChests,
AverageItemsPerLoot: ls.AverageItemsPerLoot,
AverageCoinsPerLoot: ls.AverageCoinsPerLoot,
ItemsByTier: make(map[int8]int64),
LootsByTable: make(map[int32]int64),
}
for tier, count := range ls.ItemsByTier {
copy.ItemsByTier[tier] = count
}
for tableID, count := range ls.LootsByTable {
copy.LootsByTable[tableID] = count
}
return copy
}

View File

@ -172,11 +172,21 @@ func (mil *MasterItemList) CalculateItemBonuses(itemID int32) *ItemStatsValues {
return nil
}
return mil.CalculateItemBonusesFromItem(item)
return mil.CalculateItemBonusesFromItem(item, nil)
}
// CalculateItemBonusesWithEntity calculates the stat bonuses for an item with entity-specific modifiers
func (mil *MasterItemList) CalculateItemBonusesWithEntity(itemID int32, entity Entity) *ItemStatsValues {
item := mil.GetItem(itemID)
if item == nil {
return nil
}
return mil.CalculateItemBonusesFromItem(item, entity)
}
// CalculateItemBonusesFromItem calculates stat bonuses from an item instance
func (mil *MasterItemList) CalculateItemBonusesFromItem(item *Item) *ItemStatsValues {
func (mil *MasterItemList) CalculateItemBonusesFromItem(item *Item, entity Entity) *ItemStatsValues {
if item == nil {
return nil
}

View File

@ -52,11 +52,14 @@ const (
// ItemStatsValues represents the complete stat bonuses from an item
type ItemStatsValues struct {
// Base stats
Str int16 `json:"str"`
Sta int16 `json:"sta"`
Agi int16 `json:"agi"`
Wis int16 `json:"wis"`
Int int16 `json:"int"`
// Resistances
VsSlash int16 `json:"vs_slash"`
VsCrush int16 `json:"vs_crush"`
VsPierce int16 `json:"vs_pierce"`
@ -68,9 +71,13 @@ type ItemStatsValues struct {
VsDivine int16 `json:"vs_divine"`
VsDisease int16 `json:"vs_disease"`
VsPoison int16 `json:"vs_poison"`
// Pools
Health int16 `json:"health"`
Power int16 `json:"power"`
Concentration int8 `json:"concentration"`
// Abilities and damage
AbilityModifier int16 `json:"ability_modifier"`
CriticalMitigation int16 `json:"critical_mitigation"`
ExtraShieldBlockChance int16 `json:"extra_shield_block_chance"`
@ -91,10 +98,14 @@ type ItemStatsValues struct {
Strikethrough int16 `json:"strikethrough"`
Accuracy int16 `json:"accuracy"`
OffensiveSpeed int16 `json:"offensive_speed"`
// Uncontested stats
UncontestedParry float32 `json:"uncontested_parry"`
UncontestedBlock float32 `json:"uncontested_block"`
UncontestedDodge float32 `json:"uncontested_dodge"`
UncontestedRiposte float32 `json:"uncontested_riposte"`
// Other
SizeMod float32 `json:"size_mod"`
}

20
internal/npc/npc_test.go Normal file
View File

@ -0,0 +1,20 @@
package npc
import (
"testing"
)
func TestPackageBuild(t *testing.T) {
// Basic test to verify the package builds
manager := NewNPCManager()
if manager == nil {
t.Fatal("NewNPCManager returned nil")
}
}
func TestNPCBasics(t *testing.T) {
npcData := &NPC{}
if npcData == nil {
t.Fatal("NPC struct should be accessible")
}
}

View File

@ -122,8 +122,19 @@ func (pcf *PlayerControlFlags) SendControlFlagUpdates(client *Client) {
return
}
// TODO: Implement packet sending logic
// For each change in flagChanges, create and send appropriate packets
// Send control flag updates to client
for category, flags := range pcf.flagChanges {
for flagIndex, value := range flags {
// TODO: When packet system is available, create and send appropriate packets
// packet := CreateControlFlagPacket(category, flagIndex, value)
// client.SendPacket(packet)
// For now, just log the change
_ = category
_ = flagIndex
_ = value
}
}
// Clear changes after sending
pcf.flagChanges = make(map[int8]map[int8]int8)

View File

@ -3,14 +3,14 @@ package player
// AddCoins adds coins to the player
func (p *Player) AddCoins(val int64) {
p.GetInfoStruct().AddCoin(val)
// TODO: Send update packet to client
p.sendCurrencyUpdate()
}
// RemoveCoins removes coins from the player
func (p *Player) RemoveCoins(val int64) bool {
if p.GetInfoStruct().GetCoin() >= val {
p.GetInfoStruct().SubtractCoin(val)
// TODO: Send update packet to client
p.sendCurrencyUpdate()
return true
}
return false
@ -65,3 +65,15 @@ func (p *Player) GetBankCoinsPlat() int32 {
func (p *Player) GetStatusPoints() int32 {
return p.GetInfoStruct().GetStatusPoints()
}
// sendCurrencyUpdate sends currency update packet to client
func (p *Player) sendCurrencyUpdate() {
// TODO: When packet system is available, send currency update packet
// packet := CreateCurrencyUpdatePacket(p.GetInfoStruct())
// p.GetClient().SendPacket(packet)
// For now, mark that currency has changed
if p.GetInfoStruct() != nil {
// Currency update will be sent on next info struct update
}
}

View File

@ -0,0 +1,22 @@
package player
import (
"testing"
)
func TestPackageBuild(t *testing.T) {
// Basic test to verify the package builds
manager := NewPlayerManager()
if manager == nil {
t.Fatal("NewPlayerManager returned nil")
}
}
func TestPlayerManager(t *testing.T) {
manager := NewPlayerManager()
stats := manager.GetStats()
if stats.TotalPlayers < 0 {
t.Error("Expected valid stats")
}
}

View File

@ -0,0 +1,31 @@
package spawn
import (
"testing"
)
func TestPackageBuild(t *testing.T) {
// Simple test to verify the package builds
spawn := NewSpawn()
if spawn == nil {
t.Fatal("NewSpawn returned nil")
}
if spawn.GetID() != 0 {
t.Errorf("Expected default ID 0, got %d", spawn.GetID())
}
}
func TestSpawnBasics(t *testing.T) {
spawn := NewSpawn()
spawn.SetName("Test Spawn")
if spawn.GetName() != "Test Spawn" {
t.Errorf("Expected name 'Test Spawn', got '%s'", spawn.GetName())
}
spawn.SetLevel(25)
if spawn.GetLevel() != 25 {
t.Errorf("Expected level 25, got %d", spawn.GetLevel())
}
}

View File

@ -0,0 +1,20 @@
package spells
import (
"testing"
)
func TestPackageBuild(t *testing.T) {
// Basic test to verify the package builds
spell := NewSpell()
if spell == nil {
t.Fatal("NewSpell returned nil")
}
}
func TestSpellManager(t *testing.T) {
manager := NewSpellManager()
if manager == nil {
t.Fatal("NewSpellManager returned nil")
}
}

View File

@ -3,6 +3,8 @@ package titles
import (
"fmt"
"sync"
"eq2emu/internal/database"
)
// MasterTitlesList manages all available titles in the game
@ -278,19 +280,25 @@ func (mtl *MasterTitlesList) RemoveTitle(id int32) error {
delete(mtl.titles, id)
// Remove from category index
mtl.removeFromSlice(&mtl.categorized[title.Category], title)
categorySlice := mtl.categorized[title.Category]
mtl.removeFromSlice(&categorySlice, title)
mtl.categorized[title.Category] = categorySlice
if len(mtl.categorized[title.Category]) == 0 {
delete(mtl.categorized, title.Category)
}
// Remove from source index
mtl.removeFromSlice(&mtl.bySource[title.Source], title)
sourceSlice := mtl.bySource[title.Source]
mtl.removeFromSlice(&sourceSlice, title)
mtl.bySource[title.Source] = sourceSlice
if len(mtl.bySource[title.Source]) == 0 {
delete(mtl.bySource, title.Source)
}
// Remove from rarity index
mtl.removeFromSlice(&mtl.byRarity[title.Rarity], title)
raritySlice := mtl.byRarity[title.Rarity]
mtl.removeFromSlice(&raritySlice, title)
mtl.byRarity[title.Rarity] = raritySlice
if len(mtl.byRarity[title.Rarity]) == 0 {
delete(mtl.byRarity, title.Rarity)
}
@ -328,9 +336,17 @@ func (mtl *MasterTitlesList) UpdateTitle(title *Title) error {
}
// Remove old title from indices
mtl.removeFromSlice(&mtl.categorized[existing.Category], existing)
mtl.removeFromSlice(&mtl.bySource[existing.Source], existing)
mtl.removeFromSlice(&mtl.byRarity[existing.Rarity], existing)
categorySlice := mtl.categorized[existing.Category]
mtl.removeFromSlice(&categorySlice, existing)
mtl.categorized[existing.Category] = categorySlice
sourceSlice := mtl.bySource[existing.Source]
mtl.removeFromSlice(&sourceSlice, existing)
mtl.bySource[existing.Source] = sourceSlice
raritySlice := mtl.byRarity[existing.Rarity]
mtl.removeFromSlice(&raritySlice, existing)
mtl.byRarity[existing.Rarity] = raritySlice
if existing.AchievementID > 0 {
delete(mtl.byAchievement, existing.AchievementID)
@ -405,16 +421,89 @@ func (mtl *MasterTitlesList) ValidateTitle(title *Title) error {
return nil
}
// LoadFromDatabase would load titles from the database
// TODO: Implement database integration with zone/database package
func (mtl *MasterTitlesList) LoadFromDatabase() error {
// TODO: Implement database loading
return fmt.Errorf("LoadFromDatabase not yet implemented - requires database integration")
// LoadFromDatabase loads titles from the database
func (mtl *MasterTitlesList) LoadFromDatabase(db *database.DB) error {
mtl.mutex.Lock()
defer mtl.mutex.Unlock()
// Create titles table if it doesn't exist
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS titles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
category TEXT,
position INTEGER NOT NULL DEFAULT 0,
source INTEGER NOT NULL DEFAULT 0,
rarity INTEGER NOT NULL DEFAULT 0,
flags INTEGER NOT NULL DEFAULT 0,
achievement_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
return fmt.Errorf("failed to create titles table: %w", err)
}
// Load all titles from database
err := db.Query("SELECT id, name, description, category, position, source, rarity, flags, achievement_id FROM titles", func(row *database.Row) error {
title := &Title{
ID: int32(row.Int64(0)),
Name: row.Text(1),
Description: row.Text(2),
Category: row.Text(3),
Position: int32(row.Int(4)),
Source: int32(row.Int(5)),
Rarity: int32(row.Int(6)),
Flags: uint32(row.Int64(7)),
}
// Handle nullable achievement_id
if !row.IsNull(8) {
title.AchievementID = uint32(row.Int64(8))
}
mtl.addTitleInternal(title)
return nil
})
if err != nil {
return fmt.Errorf("failed to load titles from database: %w", err)
}
return nil
}
// SaveToDatabase would save titles to the database
// TODO: Implement database integration with zone/database package
func (mtl *MasterTitlesList) SaveToDatabase() error {
// TODO: Implement database saving
return fmt.Errorf("SaveToDatabase not yet implemented - requires database integration")
// SaveToDatabase saves titles to the database
func (mtl *MasterTitlesList) SaveToDatabase(db *database.DB) error {
mtl.mutex.RLock()
defer mtl.mutex.RUnlock()
return db.Transaction(func(txDB *database.DB) error {
// Clear existing titles (this is a full sync)
if err := txDB.Exec("DELETE FROM titles"); err != nil {
return fmt.Errorf("failed to clear titles table: %w", err)
}
// Insert all current titles
for _, title := range mtl.titles {
var achievementID interface{}
if title.AchievementID != 0 {
achievementID = title.AchievementID
}
err := txDB.Exec(`
INSERT INTO titles (id, name, description, category, position, source, rarity, flags, achievement_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, title.ID, title.Name, title.Description, title.Category,
int(title.Position), int(title.Source), int(title.Rarity),
int64(title.Flags), achievementID)
if err != nil {
return fmt.Errorf("failed to insert title %d: %w", title.ID, err)
}
}
return nil
})
}

View File

@ -3,6 +3,9 @@ package titles
import (
"fmt"
"sync"
"time"
"eq2emu/internal/database"
)
// PlayerTitlesList manages titles owned by a specific player
@ -426,18 +429,106 @@ func (ptl *PlayerTitlesList) GrantTitleFromAchievement(achievementID uint32) err
return ptl.AddTitle(title.ID, achievementID, 0)
}
// LoadFromDatabase would load player titles from the database
// TODO: Implement database integration with zone/database package
func (ptl *PlayerTitlesList) LoadFromDatabase() error {
// TODO: Implement database loading
return fmt.Errorf("LoadFromDatabase not yet implemented - requires database integration")
// LoadFromDatabase loads player titles from the database
func (ptl *PlayerTitlesList) LoadFromDatabase(db *database.DB) error {
ptl.mutex.Lock()
defer ptl.mutex.Unlock()
// Create player_titles table if it doesn't exist
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS player_titles (
player_id INTEGER NOT NULL,
title_id INTEGER NOT NULL,
achievement_id INTEGER,
granted_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expiration_date TIMESTAMP,
is_active INTEGER DEFAULT 0,
PRIMARY KEY (player_id, title_id),
FOREIGN KEY (title_id) REFERENCES titles(id)
)
`); err != nil {
return fmt.Errorf("failed to create player_titles table: %w", err)
}
// Load all titles for this player
err := db.Query("SELECT title_id, achievement_id, granted_date, expiration_date, is_active FROM player_titles WHERE player_id = ?",
func(row *database.Row) error {
playerTitle := &PlayerTitle{
TitleID: int32(row.Int64(0)),
PlayerID: ptl.playerID,
EarnedDate: time.Unix(row.Int64(2), 0),
}
// Handle nullable achievement_id
if !row.IsNull(1) {
playerTitle.AchievementID = uint32(row.Int64(1))
}
// Handle nullable expiration_date
if !row.IsNull(3) {
playerTitle.ExpiresAt = time.Unix(row.Int64(3), 0)
}
ptl.titles[playerTitle.TitleID] = playerTitle
// Set active title if this one is active
if row.Bool(4) {
ptl.activePrefixID = playerTitle.TitleID
}
return nil
}, ptl.playerID)
if err != nil {
return fmt.Errorf("failed to load player titles from database: %w", err)
}
return nil
}
// SaveToDatabase would save player titles to the database
// TODO: Implement database integration with zone/database package
func (ptl *PlayerTitlesList) SaveToDatabase() error {
// TODO: Implement database saving
return fmt.Errorf("SaveToDatabase not yet implemented - requires database integration")
// SaveToDatabase saves player titles to the database
func (ptl *PlayerTitlesList) SaveToDatabase(db *database.DB) error {
ptl.mutex.RLock()
defer ptl.mutex.RUnlock()
return db.Transaction(func(txDB *database.DB) error {
// Clear existing titles for this player
if err := txDB.Exec("DELETE FROM player_titles WHERE player_id = ?", ptl.playerID); err != nil {
return fmt.Errorf("failed to clear player titles: %w", err)
}
// Insert all current titles
for _, playerTitle := range ptl.titles {
var achievementID interface{}
if playerTitle.AchievementID != 0 {
achievementID = playerTitle.AchievementID
}
var expirationDate interface{}
if !playerTitle.ExpiresAt.IsZero() {
expirationDate = playerTitle.ExpiresAt.Unix()
}
isActive := 0
if ptl.activePrefixID == playerTitle.TitleID {
isActive = 1
} else if ptl.activeSuffixID == playerTitle.TitleID {
isActive = 1
}
err := txDB.Exec(`
INSERT INTO player_titles (player_id, title_id, achievement_id, granted_date, expiration_date, is_active)
VALUES (?, ?, ?, ?, ?, ?)
`, ptl.playerID, playerTitle.TitleID, achievementID,
playerTitle.EarnedDate.Unix(), expirationDate, isActive)
if err != nil {
return fmt.Errorf("failed to insert player title %d: %w", playerTitle.TitleID, err)
}
}
return nil
})
}
// GetFormattedName returns the player name with active titles applied

View File

@ -4,6 +4,8 @@ import (
"fmt"
"sync"
"time"
"eq2emu/internal/database"
)
// TitleManager manages the entire title system for the server
@ -339,15 +341,13 @@ func (tm *TitleManager) RemovePlayerFromMemory(playerID int32) {
}
// LoadPlayerTitles loads a player's titles from database
// TODO: Implement database integration with zone/database package
func (tm *TitleManager) LoadPlayerTitles(playerID int32) error {
func (tm *TitleManager) LoadPlayerTitles(playerID int32, db *database.DB) error {
playerList := tm.GetPlayerTitles(playerID)
return playerList.LoadFromDatabase()
return playerList.LoadFromDatabase(db)
}
// SavePlayerTitles saves a player's titles to database
// TODO: Implement database integration with zone/database package
func (tm *TitleManager) SavePlayerTitles(playerID int32) error {
func (tm *TitleManager) SavePlayerTitles(playerID int32, db *database.DB) error {
tm.mutex.RLock()
playerList, exists := tm.playerLists[playerID]
tm.mutex.RUnlock()
@ -356,19 +356,17 @@ func (tm *TitleManager) SavePlayerTitles(playerID int32) error {
return fmt.Errorf("player %d has no title data to save", playerID)
}
return playerList.SaveToDatabase()
return playerList.SaveToDatabase(db)
}
// LoadMasterTitles loads all titles from database
// TODO: Implement database integration with zone/database package
func (tm *TitleManager) LoadMasterTitles() error {
return tm.masterList.LoadFromDatabase()
func (tm *TitleManager) LoadMasterTitles(db *database.DB) error {
return tm.masterList.LoadFromDatabase(db)
}
// SaveMasterTitles saves all titles to database
// TODO: Implement database integration with zone/database package
func (tm *TitleManager) SaveMasterTitles() error {
return tm.masterList.SaveToDatabase()
func (tm *TitleManager) SaveMasterTitles(db *database.DB) error {
return tm.masterList.SaveToDatabase(db)
}
// ValidateTitle validates a title before adding it

View File

@ -0,0 +1,213 @@
package titles
import (
"os"
"testing"
"eq2emu/internal/database"
)
func TestNewTitle(t *testing.T) {
title := NewTitle(1, "Test Title")
if title == nil {
t.Fatal("NewTitle returned nil")
}
if title.ID != 1 {
t.Errorf("Expected ID 1, got %d", title.ID)
}
if title.Name != "Test Title" {
t.Errorf("Expected name 'Test Title', got '%s'", title.Name)
}
if title.Position != TitlePositionSuffix {
t.Errorf("Expected default position %d, got %d", TitlePositionSuffix, title.Position)
}
}
func TestMasterTitlesList(t *testing.T) {
mtl := NewMasterTitlesList()
if mtl == nil {
t.Fatal("NewMasterTitlesList returned nil")
}
// Test default titles are loaded
citizen := mtl.GetTitle(TitleIDCitizen)
if citizen == nil {
t.Error("Expected Citizen title to be loaded by default")
}
visitor := mtl.GetTitle(TitleIDVisitor)
if visitor == nil {
t.Error("Expected Visitor title to be loaded by default")
}
// Test adding new title
testTitle := NewTitle(100, "Test Title")
err := mtl.AddTitle(testTitle)
if err != nil {
t.Fatalf("Failed to add title: %v", err)
}
retrieved := mtl.GetTitle(100)
if retrieved == nil {
t.Error("Failed to retrieve added title")
} else if retrieved.Name != "Test Title" {
t.Errorf("Expected retrieved title name 'Test Title', got '%s'", retrieved.Name)
}
}
func TestPlayerTitlesList(t *testing.T) {
mtl := NewMasterTitlesList()
ptl := NewPlayerTitlesList(123, mtl)
if ptl == nil {
t.Fatal("NewPlayerTitlesList returned nil")
}
// Test adding title
err := ptl.AddTitle(TitleIDCitizen, 0, 0)
if err != nil {
t.Fatalf("Failed to add title to player: %v", err)
}
// Test getting titles
titles := ptl.GetTitles()
if len(titles) != 1 {
t.Errorf("Expected 1 title, got %d", len(titles))
}
// Test setting active title
err = ptl.SetActiveTitle(TitleIDCitizen, TitlePositionSuffix)
if err != nil {
t.Fatalf("Failed to set active title: %v", err)
}
// Test getting active titles
activePrefix, activeSuffix := ptl.GetActiveTitles()
if activePrefix != 0 {
t.Errorf("Expected no active prefix, got %d", activePrefix)
}
if activeSuffix != TitleIDCitizen {
t.Errorf("Expected active suffix %d, got %d", TitleIDCitizen, activeSuffix)
}
}
func TestTitleManager(t *testing.T) {
tm := NewTitleManager()
if tm == nil {
t.Fatal("NewTitleManager returned nil")
}
// Test getting player titles
playerTitles := tm.GetPlayerTitles(456)
if playerTitles == nil {
t.Error("GetPlayerTitles returned nil")
}
// Test adding title for player
err := tm.GrantTitle(456, TitleIDCitizen, 0)
if err != nil {
t.Fatalf("Failed to grant title: %v", err)
}
// Verify title was granted
playerTitles = tm.GetPlayerTitles(456)
titles := playerTitles.GetTitles()
if len(titles) != 1 {
t.Errorf("Expected 1 title for player, got %d", len(titles))
}
}
func TestTitleDatabaseIntegration(t *testing.T) {
// Create temporary database
tempFile := "test_titles.db"
defer os.Remove(tempFile)
db, err := database.Open(tempFile)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Test master list database operations
mtl := NewMasterTitlesList()
// Test saving to database
err = mtl.SaveToDatabase(db)
if err != nil {
t.Fatalf("Failed to save master titles to database: %v", err)
}
// Create new master list and load from database
mtl2 := &MasterTitlesList{
titles: make(map[int32]*Title),
categorized: make(map[string][]*Title),
bySource: make(map[int32][]*Title),
byRarity: make(map[int32][]*Title),
byAchievement: make(map[uint32]*Title),
nextID: 1,
}
err = mtl2.LoadFromDatabase(db)
if err != nil {
t.Fatalf("Failed to load master titles from database: %v", err)
}
// Verify titles were loaded
citizen := mtl2.GetTitle(TitleIDCitizen)
if citizen == nil {
t.Error("Failed to load Citizen title from database")
}
// Test player titles database operations
ptl := NewPlayerTitlesList(789, mtl)
err = ptl.AddTitle(TitleIDCitizen, 0, 0)
if err != nil {
t.Fatalf("Failed to add title to player: %v", err)
}
// Save player titles
err = ptl.SaveToDatabase(db)
if err != nil {
t.Fatalf("Failed to save player titles to database: %v", err)
}
// Load player titles
ptl2 := NewPlayerTitlesList(789, mtl)
err = ptl2.LoadFromDatabase(db)
if err != nil {
t.Fatalf("Failed to load player titles from database: %v", err)
}
// Verify player titles were loaded
titles := ptl2.GetTitles()
if len(titles) != 1 {
t.Errorf("Expected 1 loaded title, got %d", len(titles))
}
}
func TestTitleValidation(t *testing.T) {
mtl := NewMasterTitlesList()
// Test nil title
err := mtl.AddTitle(nil)
if err == nil {
t.Error("Expected error when adding nil title")
}
// Test duplicate ID
title1 := NewTitle(999, "Title 1")
title2 := NewTitle(999, "Title 2")
err = mtl.AddTitle(title1)
if err != nil {
t.Fatalf("Failed to add first title: %v", err)
}
err = mtl.AddTitle(title2)
if err == nil {
t.Error("Expected error when adding title with duplicate ID")
}
}

View File

@ -2,112 +2,113 @@ package transmute
import (
"fmt"
"time"
"eq2emu/internal/database"
)
// DatabaseImpl provides a default implementation of the Database interface
type DatabaseImpl struct {
// Database connection or query executor would go here
// This is a placeholder implementation
db *database.DB
}
// NewDatabase creates a new database implementation
func NewDatabase() *DatabaseImpl {
return &DatabaseImpl{}
func NewDatabase(db *database.DB) *DatabaseImpl {
return &DatabaseImpl{db: db}
}
// LoadTransmutingTiers loads transmuting tiers from the database
func (db *DatabaseImpl) LoadTransmutingTiers() ([]*TransmutingTier, error) {
// This is a placeholder implementation
// In a real implementation, this would query the database:
// SELECT min_level, max_level, fragment, powder, infusion, mana FROM transmuting
func (dbi *DatabaseImpl) LoadTransmutingTiers() ([]*TransmutingTier, error) {
// Create transmuting_tiers table if it doesn't exist
if err := dbi.db.Exec(`
CREATE TABLE IF NOT EXISTS transmuting_tiers (
min_level INTEGER NOT NULL,
max_level INTEGER NOT NULL,
fragment_id INTEGER NOT NULL,
powder_id INTEGER NOT NULL,
infusion_id INTEGER NOT NULL,
mana_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (min_level, max_level)
)
`); err != nil {
return nil, fmt.Errorf("failed to create transmuting_tiers table: %w", err)
}
// For now, return some example tiers that match typical EQ2 level ranges
tiers := []*TransmutingTier{
{
MinLevel: 1,
MaxLevel: 9,
FragmentID: 1001, // Example fragment item ID
PowderID: 1002, // Example powder item ID
InfusionID: 1003, // Example infusion item ID
ManaID: 1004, // Example mana item ID
},
{
MinLevel: 10,
MaxLevel: 19,
FragmentID: 1005,
PowderID: 1006,
InfusionID: 1007,
ManaID: 1008,
},
{
MinLevel: 20,
MaxLevel: 29,
FragmentID: 1009,
PowderID: 1010,
InfusionID: 1011,
ManaID: 1012,
},
{
MinLevel: 30,
MaxLevel: 39,
FragmentID: 1013,
PowderID: 1014,
InfusionID: 1015,
ManaID: 1016,
},
{
MinLevel: 40,
MaxLevel: 49,
FragmentID: 1017,
PowderID: 1018,
InfusionID: 1019,
ManaID: 1020,
},
{
MinLevel: 50,
MaxLevel: 59,
FragmentID: 1021,
PowderID: 1022,
InfusionID: 1023,
ManaID: 1024,
},
{
MinLevel: 60,
MaxLevel: 69,
FragmentID: 1025,
PowderID: 1026,
InfusionID: 1027,
ManaID: 1028,
},
{
MinLevel: 70,
MaxLevel: 79,
FragmentID: 1029,
PowderID: 1030,
InfusionID: 1031,
ManaID: 1032,
},
{
MinLevel: 80,
MaxLevel: 89,
FragmentID: 1033,
PowderID: 1034,
InfusionID: 1035,
ManaID: 1036,
},
{
MinLevel: 90,
MaxLevel: 100,
FragmentID: 1037,
PowderID: 1038,
InfusionID: 1039,
ManaID: 1040,
},
// Check if table is empty and populate with default data
var count int
row, err := dbi.db.QueryRow("SELECT COUNT(*) FROM transmuting_tiers")
if err != nil {
return nil, fmt.Errorf("failed to count transmuting tiers: %w", err)
}
if row != nil {
count = row.Int(0)
row.Close()
}
// If empty, populate with default EQ2 transmuting tiers
if count == 0 {
if err := dbi.populateDefaultTiers(); err != nil {
return nil, fmt.Errorf("failed to populate default tiers: %w", err)
}
}
// Load all tiers from database
var tiers []*TransmutingTier
err = dbi.db.Query("SELECT min_level, max_level, fragment_id, powder_id, infusion_id, mana_id FROM transmuting_tiers ORDER BY min_level",
func(row *database.Row) error {
tier := &TransmutingTier{
MinLevel: int32(row.Int64(0)),
MaxLevel: int32(row.Int64(1)),
FragmentID: int32(row.Int64(2)),
PowderID: int32(row.Int64(3)),
InfusionID: int32(row.Int64(4)),
ManaID: int32(row.Int64(5)),
}
tiers = append(tiers, tier)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to load transmuting tiers: %w", err)
}
return tiers, nil
}
// populateDefaultTiers populates the database with default transmuting tiers
func (dbi *DatabaseImpl) populateDefaultTiers() error {
defaultTiers := []struct {
minLevel, maxLevel int32
fragmentID, powderID, infusionID, manaID int32
}{
{1, 9, 1001, 1002, 1003, 1004},
{10, 19, 1005, 1006, 1007, 1008},
{20, 29, 1009, 1010, 1011, 1012},
{30, 39, 1013, 1014, 1015, 1016},
{40, 49, 1017, 1018, 1019, 1020},
{50, 59, 1021, 1022, 1023, 1024},
{60, 69, 1025, 1026, 1027, 1028},
{70, 79, 1029, 1030, 1031, 1032},
{80, 89, 1033, 1034, 1035, 1036},
{90, 100, 1037, 1038, 1039, 1040},
}
return dbi.db.Transaction(func(txDB *database.DB) error {
for _, tier := range defaultTiers {
err := txDB.Exec(`
INSERT INTO transmuting_tiers (min_level, max_level, fragment_id, powder_id, infusion_id, mana_id)
VALUES (?, ?, ?, ?, ?, ?)
`, tier.minLevel, tier.maxLevel, tier.fragmentID, tier.powderID, tier.infusionID, tier.manaID)
if err != nil {
return fmt.Errorf("failed to insert tier %d-%d: %w", tier.minLevel, tier.maxLevel, err)
}
}
return nil
})
}
// TODO: When integrating with a real database system, replace this with actual database queries
// Example SQL implementation would look like:
/*
@ -148,12 +149,7 @@ func (db *DatabaseImpl) LoadTransmutingTiers() ([]*TransmutingTier, error) {
*/
// SaveTransmutingTier saves a transmuting tier to the database
func (db *DatabaseImpl) SaveTransmutingTier(tier *TransmutingTier) error {
// Placeholder implementation
// In a real implementation:
// INSERT INTO transmuting (min_level, max_level, fragment, powder, infusion, mana) VALUES (?, ?, ?, ?, ?, ?)
// OR UPDATE if exists
func (dbi *DatabaseImpl) SaveTransmutingTier(tier *TransmutingTier) error {
if tier == nil {
return fmt.Errorf("tier cannot be nil")
}
@ -171,60 +167,87 @@ func (db *DatabaseImpl) SaveTransmutingTier(tier *TransmutingTier) error {
return fmt.Errorf("all material IDs must be positive")
}
// TODO: Actual database save operation
err := dbi.db.Exec(`
INSERT OR REPLACE INTO transmuting_tiers (min_level, max_level, fragment_id, powder_id, infusion_id, mana_id)
VALUES (?, ?, ?, ?, ?, ?)
`, tier.MinLevel, tier.MaxLevel, tier.FragmentID, tier.PowderID, tier.InfusionID, tier.ManaID)
if err != nil {
return fmt.Errorf("failed to save transmuting tier %d-%d: %w", tier.MinLevel, tier.MaxLevel, err)
}
return nil
}
// DeleteTransmutingTier deletes a transmuting tier from the database
func (db *DatabaseImpl) DeleteTransmutingTier(minLevel, maxLevel int32) error {
// Placeholder implementation
// In a real implementation:
// DELETE FROM transmuting WHERE min_level = ? AND max_level = ?
func (dbi *DatabaseImpl) DeleteTransmutingTier(minLevel, maxLevel int32) error {
if minLevel <= 0 || maxLevel <= 0 {
return fmt.Errorf("invalid level range: %d-%d", minLevel, maxLevel)
}
// TODO: Actual database delete operation
err := dbi.db.Exec("DELETE FROM transmuting_tiers WHERE min_level = ? AND max_level = ?", minLevel, maxLevel)
if err != nil {
return fmt.Errorf("failed to delete transmuting tier %d-%d: %w", minLevel, maxLevel, err)
}
return nil
}
// GetTransmutingTierByLevel gets a specific transmuting tier by level range
func (db *DatabaseImpl) GetTransmutingTierByLevel(itemLevel int32) (*TransmutingTier, error) {
// Placeholder implementation
// In a real implementation:
// SELECT min_level, max_level, fragment, powder, infusion, mana FROM transmuting WHERE min_level <= ? AND max_level >= ?
tiers, err := db.LoadTransmutingTiers()
func (dbi *DatabaseImpl) GetTransmutingTierByLevel(itemLevel int32) (*TransmutingTier, error) {
row, err := dbi.db.QueryRow("SELECT min_level, max_level, fragment_id, powder_id, infusion_id, mana_id FROM transmuting_tiers WHERE min_level <= ? AND max_level >= ?", itemLevel, itemLevel)
if err != nil {
return nil, err
}
for _, tier := range tiers {
if tier.MinLevel <= itemLevel && tier.MaxLevel >= itemLevel {
return tier, nil
}
return nil, fmt.Errorf("failed to query transmuting tier for level %d: %w", itemLevel, err)
}
if row == nil {
return nil, fmt.Errorf("no transmuting tier found for level %d", itemLevel)
}
defer row.Close()
tier := &TransmutingTier{
MinLevel: int32(row.Int64(0)),
MaxLevel: int32(row.Int64(1)),
FragmentID: int32(row.Int64(2)),
PowderID: int32(row.Int64(3)),
InfusionID: int32(row.Int64(4)),
ManaID: int32(row.Int64(5)),
}
return tier, nil
}
// UpdateTransmutingTier updates an existing transmuting tier
func (db *DatabaseImpl) UpdateTransmutingTier(oldMinLevel, oldMaxLevel int32, newTier *TransmutingTier) error {
// Placeholder implementation
// In a real implementation:
// UPDATE transmuting SET min_level=?, max_level=?, fragment=?, powder=?, infusion=?, mana=? WHERE min_level=? AND max_level=?
func (dbi *DatabaseImpl) UpdateTransmutingTier(oldMinLevel, oldMaxLevel int32, newTier *TransmutingTier) error {
if newTier == nil {
return fmt.Errorf("new tier cannot be nil")
}
// Validate the new tier
if err := db.SaveTransmutingTier(newTier); err != nil {
return fmt.Errorf("invalid new tier data: %w", err)
// Validate tier data first
if newTier.MinLevel <= 0 || newTier.MaxLevel <= 0 {
return fmt.Errorf("invalid level range: %d-%d", newTier.MinLevel, newTier.MaxLevel)
}
if newTier.MinLevel > newTier.MaxLevel {
return fmt.Errorf("min level (%d) cannot be greater than max level (%d)", newTier.MinLevel, newTier.MaxLevel)
}
if newTier.FragmentID <= 0 || newTier.PowderID <= 0 || newTier.InfusionID <= 0 || newTier.ManaID <= 0 {
return fmt.Errorf("all material IDs must be positive")
}
err := dbi.db.Exec(`
UPDATE transmuting_tiers
SET min_level=?, max_level=?, fragment_id=?, powder_id=?, infusion_id=?, mana_id=?
WHERE min_level=? AND max_level=?
`, newTier.MinLevel, newTier.MaxLevel, newTier.FragmentID, newTier.PowderID,
newTier.InfusionID, newTier.ManaID, oldMinLevel, oldMaxLevel)
if err != nil {
return fmt.Errorf("failed to update transmuting tier %d-%d: %w", oldMinLevel, oldMaxLevel, err)
}
// TODO: Actual database update operation
return nil
}

372
internal/zone/README.md Normal file
View File

@ -0,0 +1,372 @@
# EverQuest II Zone System
This package implements a comprehensive zone management system for the EverQuest II server emulator, converted from the original C++ implementation while leveraging Go's concurrency and type safety features.
## Overview
The zone system handles:
- **Zone Management**: Loading, initialization, and lifecycle management of game zones
- **Instance Management**: Creating and managing instanced zones for groups, raids, and solo play
- **Spawn Management**: NPCs, objects, widgets, signs, and ground spawns with spatial optimization
- **Movement System**: NPC movement with pathfinding, stuck detection, and multiple movement modes
- **Position System**: 3D position calculations, distance functions, and EQ2-specific heading math
- **Weather System**: Dynamic weather with patterns, severity controls, and client synchronization
- **Database Integration**: Persistent storage of zone configuration, spawns, and player data
- **Client Management**: Player connections, spawn visibility, and packet communication
- **Grid System**: Spatial partitioning for efficient range queries and spawn management
## Architecture
### Core Components
#### ZoneServer (`zone_server.go`)
- Central coordinator for all zone functionality
- Manages clients, spawns, timers, and processing loops
- Handles zone initialization, configuration, and shutdown
- Thread-safe operations using sync.RWMutex and atomic values
- Supports both regular zones and instances
#### ZoneManager (`zone_manager.go`)
- High-level management of multiple zones and instances
- Automatic loading/unloading based on demand
- Instance creation with type-specific player limits
- Statistics collection and monitoring
- Cleanup of inactive instances
#### MobMovementManager (`movement_manager.go`)
- Advanced NPC movement system with command queuing
- Pathfinding integration with multiple backends
- Stuck detection and recovery mechanisms
- Multiple movement modes (walk, run, swim, fly)
- Thread-safe processing with delta time calculations
#### Position System (`position.go`)
- EverQuest II specific 3D math utilities
- Distance calculations (2D, 3D, 4D with heading)
- Heading conversion and normalization (512-unit circle)
- Bounding box and cylinder collision detection
- Interpolation and random position generation
#### Database Layer (`database.go`)
- Complete zone data persistence with prepared statements
- Transaction support for atomic updates
- Efficient loading of zone configuration and spawn data
- Support for spawn locations, groups, and associations
- Thread-safe operations with connection pooling
### Key Features
#### Spatial Optimization
- **Grid System**: Spatial partitioning for efficient spawn queries
- **Range-based Updates**: Only process spawns within client visibility
- **Distance Culling**: Automatic spawn loading/unloading based on distance
- **Grid-based Indexing**: Fast lookup of nearby spawns and objects
#### Instance System
- **Multiple Instance Types**: Group, raid, solo, tradeskill, housing, quest instances
- **Automatic Limits**: Type-specific player count restrictions
- **Lifecycle Management**: Automatic creation and cleanup
- **Persistence Options**: Lockout vs persistent instances
#### Movement System
- **Command Queuing**: Sequential movement command execution
- **Pathfinding Integration**: Multiple pathfinding backends (navmesh, waypoint, null)
- **Stuck Detection**: Position-based stuck detection with recovery strategies
- **Smooth Movement**: Delta-time based position interpolation
- **Multi-mode Support**: Walking, running, swimming, flying
#### Weather System
- **Dynamic Patterns**: Normal, dynamic, random, and chaotic weather types
- **Severity Control**: Min/max bounds with configurable change rates
- **Pattern Support**: Increasing, decreasing, and random severity patterns
- **Client Synchronization**: Automatic weather updates to all clients
## Usage Examples
### Basic Zone Creation and Management
```go
// Create zone server
zoneServer := NewZoneServer("qeynos")
// Configure zone
config := &ZoneServerConfig{
ZoneName: "qeynos",
ZoneFile: "qeynos.zone",
ZoneDescription: "Qeynos: Capitol of Antonica",
ZoneID: 100,
InstanceID: 0,
InstanceType: InstanceTypeNone,
MaxPlayers: 200,
MinLevel: 1,
MaxLevel: 100,
SafeX: 830.0,
SafeY: -25.0,
SafeZ: -394.0,
SafeHeading: 0.0,
LoadMaps: true,
EnableWeather: true,
EnablePathfinding: true,
}
// Initialize zone
err := zoneServer.Initialize(config)
if err != nil {
log.Fatal(err)
}
// Add client to zone
err = zoneServer.AddClient(client)
if err != nil {
log.Printf("Failed to add client: %v", err)
}
```
### Zone Manager Usage
```go
// Create zone manager
config := &ZoneManagerConfig{
MaxZones: 50,
MaxInstanceZones: 500,
ProcessInterval: time.Millisecond * 100,
CleanupInterval: time.Minute * 5,
EnableWeather: true,
EnablePathfinding: true,
}
zoneManager := NewZoneManager(config, database)
// Start zone manager
err := zoneManager.Start()
if err != nil {
log.Fatal(err)
}
// Load a zone
zone, err := zoneManager.LoadZone(100) // Qeynos
if err != nil {
log.Printf("Failed to load zone: %v", err)
}
// Create an instance
instance, err := zoneManager.CreateInstance(100, InstanceTypeGroupLockout, playerID)
if err != nil {
log.Printf("Failed to create instance: %v", err)
}
```
### Movement System Usage
```go
// Get movement manager from zone
movementMgr := zoneServer.movementMgr
// Add NPC to movement tracking
spawnID := int32(1001)
movementMgr.AddMovementSpawn(spawnID)
// Command NPC to move
err := movementMgr.MoveTo(spawnID, 100.0, 200.0, 0.0, DefaultRunSpeed)
if err != nil {
log.Printf("Movement command failed: %v", err)
}
// Queue multiple commands
movementMgr.MoveTo(spawnID, 150.0, 250.0, 0.0, DefaultWalkSpeed)
movementMgr.RotateTo(spawnID, 256.0, 90.0) // Turn around
movementMgr.MoveTo(spawnID, 100.0, 200.0, 0.0, DefaultRunSpeed) // Return
// Check if moving
if movementMgr.IsMoving(spawnID) {
state := movementMgr.GetMovementState(spawnID)
log.Printf("NPC %d is moving at speed %.2f", spawnID, state.Speed)
}
```
### Position Calculations
```go
// Calculate distance between two points
distance := Distance3D(0, 0, 0, 100, 100, 100)
log.Printf("Distance: %.2f", distance)
// Calculate heading from one point to another
heading := CalculateHeading(0, 0, 100, 100)
log.Printf("Heading: %.2f", heading)
// Work with positions
pos1 := NewPosition(10.0, 20.0, 30.0, 128.0)
pos2 := NewPosition(50.0, 60.0, 30.0, 256.0)
distance = pos1.DistanceTo3D(pos2)
log.Printf("Position distance: %.2f", distance)
// Check if positions are within range
if IsWithinRange(pos1, pos2, 100.0) {
log.Println("Positions are within range")
}
// Create bounding box and test containment
bbox := NewBoundingBox(0, 0, 0, 100, 100, 100)
if bbox.ContainsPosition(pos1) {
log.Println("Position is inside bounding box")
}
```
### Weather System
```go
// Set rain level
zoneServer.SetRain(0.8) // Heavy rain
// Weather is processed automatically, but can be triggered manually
zoneServer.ProcessWeather()
// Configure weather (typically done during initialization)
zoneServer.weatherEnabled = true
zoneServer.weatherType = WeatherTypeDynamic
zoneServer.weatherFrequency = 600 // 10 minutes
zoneServer.weatherMinSeverity = 0.0
zoneServer.weatherMaxSeverity = 1.0
zoneServer.weatherChangeAmount = 0.1
zoneServer.weatherChangeChance = 75 // 75% chance of change
```
## Database Schema
The zone system uses several database tables:
### Core Zone Configuration
```sql
CREATE TABLE zones (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
file TEXT,
description TEXT,
safe_x REAL DEFAULT 0,
safe_y REAL DEFAULT 0,
safe_z REAL DEFAULT 0,
safe_heading REAL DEFAULT 0,
underworld REAL DEFAULT -1000,
min_level INTEGER DEFAULT 0,
max_level INTEGER DEFAULT 0,
max_players INTEGER DEFAULT 100,
instance_type INTEGER DEFAULT 0,
expansion_flag INTEGER DEFAULT 0,
weather_allowed INTEGER DEFAULT 1,
-- ... additional fields
);
```
### Spawn Locations
```sql
CREATE TABLE spawn_location_placement (
id INTEGER PRIMARY KEY,
zone_id INTEGER,
x REAL,
y REAL,
z REAL,
heading REAL,
spawn_type INTEGER,
respawn_time INTEGER DEFAULT 600,
conditions INTEGER DEFAULT 0,
spawn_percentage REAL DEFAULT 100.0
);
```
### Spawn Groups
```sql
CREATE TABLE spawn_location_group (
group_id INTEGER,
location_id INTEGER,
zone_id INTEGER
);
```
## Configuration
### Key Constants
- **Distance Constants**: `SendSpawnDistance` (250), `RemoveSpawnDistance` (300)
- **Movement Speeds**: `DefaultWalkSpeed` (2.5), `DefaultRunSpeed` (7.0)
- **Timer Intervals**: Configurable processing intervals for different systems
- **Capacity Limits**: `MaxSpawnsPerGrid` (100), `MaxClientsPerZone` (200)
### Zone Rules
Zones can be configured with various rules:
- Player level restrictions (min/max)
- Client version requirements
- PvP enablement
- Weather settings
- Instance type and capacity
- Expansion and holiday flags
## Thread Safety
All zone operations are designed to be thread-safe:
- **RWMutex Usage**: Separate read/write locks for different data structures
- **Atomic Operations**: For simple flags and counters
- **Channel Communication**: For cross-goroutine messaging
- **Immutable Data**: Where possible, data structures are immutable
- **Copy-on-Read**: Returns copies of data to prevent race conditions
## Performance Considerations
- **Spatial Indexing**: Grid-based partitioning reduces O(n) to O(1) for range queries
- **Prepared Statements**: All database queries use prepared statements
- **Object Pooling**: Reuse of frequently allocated objects
- **Lazy Loading**: Zone data loaded on demand
- **Concurrent Processing**: Multiple goroutines for different subsystems
- **Memory Management**: Regular cleanup of expired objects and timers
## Error Handling
The zone system provides comprehensive error handling:
- **Graceful Degradation**: Systems continue operating when non-critical components fail
- **Detailed Logging**: All errors logged with appropriate prefixes and context
- **Recovery Mechanisms**: Automatic recovery from common error conditions
- **Validation**: Input validation at all API boundaries
- **Timeouts**: All operations have appropriate timeouts
## Testing
Comprehensive test suite includes:
- Unit tests for all major components
- Integration tests for database operations
- Performance benchmarks for critical paths
- Mock implementations for testing isolation
- Property-based testing for mathematical functions
Run tests with:
```bash
go test ./internal/zone/...
go test -race ./internal/zone/... # Race condition detection
go test -bench=. ./internal/zone/ # Performance benchmarks
```
## Migration from C++
This Go implementation maintains compatibility with the original C++ EQ2EMu zone system:
- **Database Schema**: Identical table structure and relationships
- **Protocol Compatibility**: Same client communication protocols
- **Algorithmic Equivalence**: Math functions produce identical results
- **Configuration Format**: Compatible configuration files and settings
- **Performance**: Comparable or improved performance through Go's concurrency
## Dependencies
- **Standard Library**: sync, time, database/sql, math
- **Internal Packages**: database, spawn, common
- **External**: SQLite driver (zombiezen.com/go/sqlite)
## Future Enhancements
Planned improvements include:
- **Advanced Pathfinding**: Integration with Detour navigation mesh
- **Lua Scripting**: Full Lua integration for spawn behaviors
- **Physics Engine**: Advanced collision detection and physics
- **Clustering**: Multi-server zone distribution
- **Hot Reloading**: Dynamic configuration updates without restart

531
internal/zone/constants.go Normal file
View File

@ -0,0 +1,531 @@
package zone
// Configuration keys for zone rules and settings
const (
// Zone configuration keys
ConfigTreasureChestEnabled = "treasure_chest_enabled"
ConfigPvPEnabled = "pvp_enabled"
ConfigDeathPenalty = "death_penalty_enabled"
ConfigSafeZone = "safe_zone"
ConfigGroupSpawns = "group_spawns_allowed"
ConfigInstanced = "instanced"
ConfigMaxPlayers = "max_players"
ConfigMinLevel = "min_level"
ConfigMaxLevel = "max_level"
ConfigLockoutTime = "lockout_time"
ConfigReenterTime = "reenter_time"
ConfigResetTime = "reset_time"
ConfigWeatherEnabled = "weather_enabled"
ConfigWeatherType = "weather_type"
ConfigSpawnRates = "spawn_rate_modifier"
ConfigLootRates = "loot_rate_modifier"
ConfigExperienceRates = "experience_rate_modifier"
)
// Logging prefixes for consistent log formatting
const (
LogPrefixZone = "[ZONE]"
LogPrefixMap = "[MAP]"
LogPrefixPathfind = "[PATHFIND]"
LogPrefixMovement = "[MOVEMENT]"
LogPrefixRegion = "[REGION]"
LogPrefixWeather = "[WEATHER]"
LogPrefixTransport = "[TRANSPORT]"
LogPrefixFlight = "[FLIGHT]"
LogPrefixProximity = "[PROXIMITY]"
LogPrefixGrid = "[GRID]"
LogPrefixScript = "[SCRIPT]"
LogPrefixCombat = "[COMBAT]"
LogPrefixSpawn = "[SPAWN]"
LogPrefixClient = "[CLIENT]"
)
// Map version constants
const (
MapVersionV1 = 1
MapVersionV2 = 2
MapVersionV2Deflated = 3
MapVersionV3Deflated = 4
)
// Map loading states
const (
MapStateUnloaded = iota
MapStateLoading
MapStateLoaded
MapStateFailed
)
// Pathfinding backend types
const (
PathfinderTypeNull = iota
PathfinderTypeWaypoint
PathfinderTypeNavmesh
)
// Pathfinding poly flags for terrain types
const (
PathingPolyFlagWalk = 0x01
PathingPolyFlagSwim = 0x02
PathingPolyFlagDoor = 0x04
PathingPolyFlagJump = 0x08
PathingPolyFlagDisabled = 0x10
PathingPolyFlagAll = 0xFFFF
)
// Water types for region detection
const (
WaterTypeNone = iota
WaterTypeNormal
WaterTypeLava
WaterTypeSlime
WaterTypeIce
WaterTypeVirtual
)
// Region types
const (
RegionTypeNormal = iota
RegionTypeWater
RegionTypeLava
RegionTypePvP
RegionTypeZoneLine
RegionTypeNoSpawn
RegionTypeEnvironmentalDamage
)
// Environmental damage types
const (
EnvironmentalDamageNone = iota
EnvironmentalDamageLava
EnvironmentalDamagePoison
EnvironmentalDamageCold
EnvironmentalDamageHeat
)
// Movement command types
const (
MovementCommandMoveTo = iota
MovementCommandSwimTo
MovementCommandTeleportTo
MovementCommandRotateTo
MovementCommandStop
MovementCommandEvadeCombat
MovementCommandFollowPath
)
// Movement modes
const (
MovementModeWalk = iota
MovementModeRun
MovementModeSwim
MovementModeFly
)
// Stuck behaviors for movement
const (
StuckBehaviorNone = iota
StuckBehaviorRun
StuckBehaviorWarp
StuckBehaviorEvade
)
// Movement speeds (units per second)
const (
DefaultWalkSpeed = 2.5
DefaultRunSpeed = 7.0
DefaultSwimSpeed = 3.5
DefaultFlySpeed = 10.0
)
// NPC brain types
const (
BrainTypeNone = iota
BrainTypeBasic
BrainTypeCombatPet
BrainTypeNonCombatPet
BrainTypeBlank
BrainTypeLua
BrainTypeDumbFire
)
// Combat states
const (
CombatStateNone = iota
CombatStateInCombat
CombatStateEvading
CombatStateReturning
)
// NPC AI timing constants (in milliseconds)
const (
AIThinkInterval = 100 // How often AI thinks
AggroCheckInterval = 1000 // How often to check for new targets
MovementUpdateInterval = 250 // How often to update movement
CombatTickInterval = 1000 // How often to process combat
SpellCheckInterval = 500 // How often to check for spell casting
HateDecayInterval = 5000 // How often hate decays
)
// Combat ranges
const (
DefaultMeleeRange = 5.0 // Default melee attack range
DefaultSpellRange = 30.0 // Default spell casting range
DefaultAggroRange = 25.0 // Default aggro acquisition range
DefaultLeashRange = 100.0 // Default leash range before reset
DefaultSocialRange = 15.0 // Range for social aggro
)
// Hate system constants
const (
MaxHateValue = 2000000000 // Maximum hate value
MinHateValue = -MaxHateValue
InitialHateValue = 1 // Starting hate for new targets
HateDecayAmount = 1 // How much hate decays per interval
HateDecayMinimum = 1 // Minimum hate before removal
)
// Spell targeting types
const (
SpellTargetSelf = iota
SpellTargetSingle
SpellTargetGroup
SpellTargetRaid
SpellTargetAOE
SpellTargetPBAOE
SpellTargetPet
)
// Spell effect types
const (
SpellEffectHeal = iota
SpellEffectDamage
SpellEffectBuff
SpellEffectDebuff
SpellEffectSummon
SpellEffectTeleport
SpellEffectResurrect
)
// Damage types
const (
DamageTypeSlashing = iota
DamageTypeCrushing
DamageTypePiercing
DamageTypeHeat
DamageTypeCold
DamageTypeMagic
DamageTypeMental
DamageTypePoison
DamageTypeDisease
)
// Resist types
const (
ResistElemental = iota
ResistNoxious
ResistArcane
ResistPhysical
)
// Timer intervals (in milliseconds)
const (
DefaultTimerInterval = 1000 // 1 second
FastTimerInterval = 100 // 100ms for high-frequency updates
SlowTimerInterval = 5000 // 5 seconds for infrequent updates
SpawnRangeUpdateInterval = 1000 // How often to update spawn ranges
SpawnVisibilityInterval = 500 // How often to check spawn visibility
CharsheetUpdateInterval = 30000 // How often to update character sheets
ClientSaveInterval = 300000 // How often to save client data
WeatherUpdateInterval = 60000 // How often to update weather
LocationProximityInterval = 1000 // How often to check location proximity
PlayerProximityInterval = 500 // How often to check player proximity
TrackingUpdateInterval = 2000 // How often to update tracking
WidgetUpdateInterval = 100 // How often to update widgets
RespawnCheckInterval = 5000 // How often to check respawns
DeadSpawnCleanupInterval = 30000 // How often to clean up dead spawns
ScriptTimerCheckInterval = 100 // How often to check script timers
)
// Packet opcodes and types
const (
PacketTypeUpdateLoot = "UpdateLoot"
PacketTypeLootResponse = "LootResponse"
PacketTypeStoppedLooting = "StoppedLooting"
PacketTypeSpawnUpdate = "SpawnUpdate"
PacketTypeRemoveSpawn = "RemoveSpawn"
PacketTypeAddSpawn = "AddSpawn"
PacketTypeZoneInfo = "ZoneInfo"
PacketTypeWeatherUpdate = "WeatherUpdate"
PacketTypeTimeUpdate = "TimeUpdate"
PacketTypeChatMessage = "ChatMessage"
PacketTypeEmote = "Emote"
PacketTypeAnimation = "Animation"
)
// Chat channel types
const (
ChatChannelSay = iota
ChatChannelTell
ChatChannelGroup
ChatChannelRaid
ChatChannelGuild
ChatChannelBroadcast
ChatChannelAnnouncement
ChatChannelOOC
ChatChannelYell
ChatChannelAuction
)
// Animation types
const (
AnimationTypeStandard = iota
AnimationTypeLooping
AnimationTypeTriggered
)
// Visual state types
const (
VisualStateStun = iota
VisualStateRoot
VisualStateFear
VisualStateMezz
VisualStateStifle
VisualStateInvisible
VisualStateStealth
)
// Emote types
const (
EmoteTypeStandard = iota
EmoteTypeTargeted
EmoteTypeUntargeted
)
// Tradeskill recipe difficulty levels
const (
RecipeDifficultyTrivial = iota
RecipeDifficultyEasy
RecipeDifficultyMedium
RecipeDifficultyHard
RecipeDifficultyExpert
)
// Harvest skill names
const (
HarvestSkillMining = "Mining"
HarvestSkillForesting = "Foresting"
HarvestSkillFishing = "Fishing"
HarvestSkillTrapping = "Trapping"
HarvestSkillGathering = "Gathering"
)
// Transport types
const (
TransportTypeNormal = iota
TransportTypeGuild
TransportTypeBoat
TransportTypeFlight
TransportTypeTeleporter
)
// Flight path states
const (
FlightPathStateNone = iota
FlightPathStateStarting
FlightPathStateFlying
FlightPathStateLanding
FlightPathStateCompleted
)
// Widget types
const (
WidgetTypeDoor = iota
WidgetTypeLift
WidgetTypeTransporter
WidgetTypeGeneric
)
// Widget states
const (
WidgetStateClosed = iota
WidgetStateOpen
WidgetStateMoving
)
// Sign types
const (
SignTypeGeneric = iota
SignTypeZoneTransport
)
// Ground spawn states
const (
GroundSpawnStateAvailable = iota
GroundSpawnStateHarvesting
GroundSpawnStateDepeted
GroundSpawnStateRespawning
)
// Group loot methods
const (
GroupLootMethodFFA = iota
GroupLootMethodRoundRobin
GroupLootMethodMasterLooter
GroupLootMethodNeedGreed
GroupLootMethodLotto
)
// Default configuration values
const (
DefaultMaxPlayers = 100
DefaultInstanceLockoutTime = 18000 // 5 hours in seconds
DefaultInstanceReenterTime = 3600 // 1 hour in seconds
DefaultInstanceResetTime = 259200 // 72 hours in seconds
DefaultRespawnTime = 600 // 10 minutes in seconds
DefaultSpawnDeleteTimer = 300 // 5 minutes in seconds
DefaultWeatherFrequency = 600 // 10 minutes in seconds
DefaultWeatherMinSeverity = 0.0
DefaultWeatherMaxSeverity = 1.0
DefaultWeatherChangeAmount = 0.1
DefaultWeatherDynamicOffset = 0.2
DefaultWeatherChangeChance = 50 // 50% chance
)
// Error messages
const (
ErrZoneNotFound = "zone not found"
ErrZoneShuttingDown = "zone is shutting down"
ErrZoneNotInitialized = "zone not initialized"
ErrMapNotLoaded = "map not loaded"
ErrPathfindingFailed = "pathfinding failed"
ErrInvalidPosition = "invalid position"
ErrSpawnNotFound = "spawn not found"
ErrPlayerNotFound = "player not found"
ErrClientNotFound = "client not found"
ErrInvalidSpawnType = "invalid spawn type"
ErrInvalidMovementCommand = "invalid movement command"
ErrNoPathAvailable = "no path available"
ErrTargetTooFar = "target too far away"
ErrNotInRange = "not in range"
ErrInvalidTarget = "invalid target"
ErrCannotCast = "cannot cast spell"
ErrInsufficientPower = "insufficient power"
ErrSpellOnCooldown = "spell on cooldown"
ErrInterrupted = "spell interrupted"
)
// File extensions
const (
MapFileExtension = ".map"
RegionFileExtension = ".rgn"
NavmeshFileExtension = ".nav"
WaypointFileExtension = ".wpt"
)
// Default file paths
const (
DefaultMapsPath = "maps/"
DefaultRegionsPath = "regions/"
DefaultNavmeshPath = "navmesh/"
DefaultWaypointsPath = "waypoints/"
)
// Grid system constants
const (
DefaultGridSize = 100.0 // Grid cell size in world units
MaxGridID = 1000 // Maximum grid ID
GridUpdateRadius = 2 // How many grid cells to update around player
)
// Proximity system constants
const (
MaxProximityDistance = 500.0 // Maximum proximity detection distance
ProximityUpdateRadius = 250.0 // Radius for proximity updates
LocationProximityRadius = 10.0 // Default radius for location proximity
)
// Memory and performance constants
const (
MaxSpawnsPerGrid = 100 // Maximum spawns per grid cell
MaxClientsPerZone = 200 // Maximum clients per zone
MaxTrackedSpawns = 500 // Maximum tracked spawns per client
SpawnPoolSize = 1000 // Size of spawn object pool
PacketBufferSize = 4096 // Size of packet buffers
MaxConcurrentLoaders = 4 // Maximum concurrent map loaders
)
// Version compatibility
const (
MinSupportedClientVersion = 1
MaxSupportedClientVersion = 60114
DefaultClientVersion = 60114
)
// Database query limits
const (
MaxSpawnLocationsPerQuery = 1000
MaxLootTablesPerQuery = 100
MaxNPCsPerQuery = 500
MaxObjectsPerQuery = 200
DatabaseQueryTimeout = 30 // seconds
)
// Thread pool sizes
const (
DefaultWorkerThreads = 4
IOWorkerThreads = 2
NetworkWorkerThreads = 2
DatabaseWorkerThreads = 2
)
// Cache sizes
const (
SpawnCacheSize = 10000
PlayerCacheSize = 1000
ItemCacheSize = 50000
SpellCacheSize = 10000
QuestCacheSize = 5000
)
// Cleanup intervals
const (
DeadSpawnCleanupTime = 300 // 5 minutes before dead spawn cleanup
ExpiredTimersCleanup = 60 // 1 minute to clean expired timers
InactiveClientCleanup = 3600 // 1 hour for inactive client cleanup
MemoryCleanupInterval = 300 // 5 minutes for general memory cleanup
)
// Language system constants
const (
CommonLanguageID = 0 // Common tongue (understood by all)
MaxLanguageSkill = 100 // Maximum language skill value
DefaultLanguageSkill = 25 // Default language skill for racial languages
)
// Quest system constants
const (
MaxActiveQuests = 75 // Maximum active quests per player
MaxCompletedQuests = 1000 // Maximum completed quests to track
QuestUpdateRadius = 50.0 // Radius for quest update notifications
)
// PvP system constants
const (
PvPSafeZoneRadius = 100.0 // Radius around safe zones where PvP is disabled
PvPCombatTimeout = 30 // Seconds before PvP combat timeout
PvPFlagDuration = 300 // Duration of PvP flag in seconds
)
// Housing system constants
const (
MaxHouseItems = 800 // Maximum items per house
HouseMaintenanceDays = 30 // Days before house maintenance due
MaxHouseSize = 100.0 // Maximum house dimensions
)
// Achievement system constants
const (
MaxAchievementPoints = 50000 // Maximum achievement points
AchievementUpdateRadius = 100.0 // Radius for achievement notifications
)

777
internal/zone/database.go Normal file
View File

@ -0,0 +1,777 @@
package zone
import (
"database/sql"
"fmt"
"log"
"sync"
)
// ZoneDatabase handles all database operations for zones
type ZoneDatabase struct {
db *sql.DB
queries map[string]*sql.Stmt
mutex sync.RWMutex
}
// NewZoneDatabase creates a new zone database instance
func NewZoneDatabase(db *sql.DB) *ZoneDatabase {
zdb := &ZoneDatabase{
db: db,
queries: make(map[string]*sql.Stmt),
}
if err := zdb.prepareStatements(); err != nil {
log.Printf("%s Failed to prepare database statements: %v", LogPrefixZone, err)
}
return zdb
}
// LoadZoneData loads all zone configuration and spawn data
func (zdb *ZoneDatabase) LoadZoneData(zoneID int32) (*ZoneData, error) {
zoneData := &ZoneData{
ZoneID: zoneID,
}
// Load zone configuration
if err := zdb.loadZoneConfiguration(zoneData); err != nil {
return nil, fmt.Errorf("failed to load zone configuration: %v", err)
}
// Load spawn locations
if err := zdb.loadSpawnLocations(zoneData); err != nil {
return nil, fmt.Errorf("failed to load spawn locations: %v", err)
}
// Load spawn entries
if err := zdb.loadSpawnEntries(zoneData); err != nil {
return nil, fmt.Errorf("failed to load spawn entries: %v", err)
}
// Load NPCs
if err := zdb.loadNPCs(zoneData); err != nil {
return nil, fmt.Errorf("failed to load NPCs: %v", err)
}
// Load objects
if err := zdb.loadObjects(zoneData); err != nil {
return nil, fmt.Errorf("failed to load objects: %v", err)
}
// Load widgets
if err := zdb.loadWidgets(zoneData); err != nil {
return nil, fmt.Errorf("failed to load widgets: %v", err)
}
// Load signs
if err := zdb.loadSigns(zoneData); err != nil {
return nil, fmt.Errorf("failed to load signs: %v", err)
}
// Load ground spawns
if err := zdb.loadGroundSpawns(zoneData); err != nil {
return nil, fmt.Errorf("failed to load ground spawns: %v", err)
}
// Load transporters
if err := zdb.loadTransporters(zoneData); err != nil {
return nil, fmt.Errorf("failed to load transporters: %v", err)
}
// Load location grids
if err := zdb.loadLocationGrids(zoneData); err != nil {
return nil, fmt.Errorf("failed to load location grids: %v", err)
}
// Load revive points
if err := zdb.loadRevivePoints(zoneData); err != nil {
return nil, fmt.Errorf("failed to load revive points: %v", err)
}
log.Printf("%s Loaded zone data for zone %d", LogPrefixZone, zoneID)
return zoneData, nil
}
// SaveZoneConfiguration saves zone configuration to database
func (zdb *ZoneDatabase) SaveZoneConfiguration(config *ZoneConfiguration) error {
zdb.mutex.Lock()
defer zdb.mutex.Unlock()
stmt := zdb.queries["updateZoneConfig"]
if stmt == nil {
return fmt.Errorf("update zone config statement not prepared")
}
_, err := stmt.Exec(
config.Name,
config.File,
config.Description,
config.SafeX,
config.SafeY,
config.SafeZ,
config.SafeHeading,
config.Underworld,
config.MinLevel,
config.MaxLevel,
config.MinStatus,
config.MinVersion,
config.InstanceType,
config.MaxPlayers,
config.DefaultLockoutTime,
config.DefaultReenterTime,
config.DefaultResetTime,
config.GroupZoneOption,
config.ExpansionFlag,
config.HolidayFlag,
config.CanBind,
config.CanGate,
config.CanEvac,
config.CityZone,
config.AlwaysLoaded,
config.WeatherAllowed,
config.ZoneID,
)
if err != nil {
return fmt.Errorf("failed to save zone configuration: %v", err)
}
return nil
}
// LoadSpawnLocation loads a specific spawn location
func (zdb *ZoneDatabase) LoadSpawnLocation(locationID int32) (*SpawnLocation, error) {
zdb.mutex.RLock()
defer zdb.mutex.RUnlock()
stmt := zdb.queries["selectSpawnLocation"]
if stmt == nil {
return nil, fmt.Errorf("select spawn location statement not prepared")
}
location := &SpawnLocation{}
err := stmt.QueryRow(locationID).Scan(
&location.ID,
&location.X,
&location.Y,
&location.Z,
&location.Heading,
&location.Pitch,
&location.Roll,
&location.SpawnType,
&location.RespawnTime,
&location.ExpireTime,
&location.ExpireOffset,
&location.Conditions,
&location.ConditionalValue,
&location.SpawnPercentage,
)
if err != nil {
return nil, fmt.Errorf("failed to load spawn location %d: %v", locationID, err)
}
return location, nil
}
// SaveSpawnLocation saves a spawn location to database
func (zdb *ZoneDatabase) SaveSpawnLocation(location *SpawnLocation) error {
zdb.mutex.Lock()
defer zdb.mutex.Unlock()
var stmt *sql.Stmt
var err error
if location.ID == 0 {
// Insert new location
stmt = zdb.queries["insertSpawnLocation"]
if stmt == nil {
return fmt.Errorf("insert spawn location statement not prepared")
}
err = stmt.QueryRow(
location.X,
location.Y,
location.Z,
location.Heading,
location.Pitch,
location.Roll,
location.SpawnType,
location.RespawnTime,
location.ExpireTime,
location.ExpireOffset,
location.Conditions,
location.ConditionalValue,
location.SpawnPercentage,
).Scan(&location.ID)
} else {
// Update existing location
stmt = zdb.queries["updateSpawnLocation"]
if stmt == nil {
return fmt.Errorf("update spawn location statement not prepared")
}
_, err = stmt.Exec(
location.X,
location.Y,
location.Z,
location.Heading,
location.Pitch,
location.Roll,
location.SpawnType,
location.RespawnTime,
location.ExpireTime,
location.ExpireOffset,
location.Conditions,
location.ConditionalValue,
location.SpawnPercentage,
location.ID,
)
}
if err != nil {
return fmt.Errorf("failed to save spawn location: %v", err)
}
return nil
}
// DeleteSpawnLocation deletes a spawn location from database
func (zdb *ZoneDatabase) DeleteSpawnLocation(locationID int32) error {
zdb.mutex.Lock()
defer zdb.mutex.Unlock()
stmt := zdb.queries["deleteSpawnLocation"]
if stmt == nil {
return fmt.Errorf("delete spawn location statement not prepared")
}
_, err := stmt.Exec(locationID)
if err != nil {
return fmt.Errorf("failed to delete spawn location %d: %v", locationID, err)
}
return nil
}
// LoadSpawnGroups loads spawn group associations for a zone
func (zdb *ZoneDatabase) LoadSpawnGroups(zoneID int32) (map[int32][]int32, error) {
zdb.mutex.RLock()
defer zdb.mutex.RUnlock()
stmt := zdb.queries["selectSpawnGroups"]
if stmt == nil {
return nil, fmt.Errorf("select spawn groups statement not prepared")
}
rows, err := stmt.Query(zoneID)
if err != nil {
return nil, fmt.Errorf("failed to query spawn groups: %v", err)
}
defer rows.Close()
groups := make(map[int32][]int32)
for rows.Next() {
var groupID, locationID int32
if err := rows.Scan(&groupID, &locationID); err != nil {
return nil, fmt.Errorf("failed to scan spawn group row: %v", err)
}
groups[groupID] = append(groups[groupID], locationID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating spawn groups: %v", err)
}
return groups, nil
}
// SaveSpawnGroup saves spawn group associations
func (zdb *ZoneDatabase) SaveSpawnGroup(groupID int32, locationIDs []int32) error {
zdb.mutex.Lock()
defer zdb.mutex.Unlock()
// Start transaction
tx, err := zdb.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %v", err)
}
defer tx.Rollback()
// Delete existing associations
deleteStmt := zdb.queries["deleteSpawnGroup"]
if deleteStmt == nil {
return fmt.Errorf("delete spawn group statement not prepared")
}
_, err = tx.Stmt(deleteStmt).Exec(groupID)
if err != nil {
return fmt.Errorf("failed to delete existing spawn group: %v", err)
}
// Insert new associations
insertStmt := zdb.queries["insertSpawnGroup"]
if insertStmt == nil {
return fmt.Errorf("insert spawn group statement not prepared")
}
for _, locationID := range locationIDs {
_, err = tx.Stmt(insertStmt).Exec(groupID, locationID)
if err != nil {
return fmt.Errorf("failed to insert spawn group association: %v", err)
}
}
// Commit transaction
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit spawn group transaction: %v", err)
}
return nil
}
// Close closes all prepared statements and database connection
func (zdb *ZoneDatabase) Close() error {
zdb.mutex.Lock()
defer zdb.mutex.Unlock()
// Close all prepared statements
for name, stmt := range zdb.queries {
if err := stmt.Close(); err != nil {
log.Printf("%s Error closing statement %s: %v", LogPrefixZone, name, err)
}
}
zdb.queries = make(map[string]*sql.Stmt)
return nil
}
// Private helper methods
func (zdb *ZoneDatabase) prepareStatements() error {
statements := map[string]string{
"updateZoneConfig": `
UPDATE zones SET
name = ?, file = ?, description = ?, safe_x = ?, safe_y = ?, safe_z = ?,
safe_heading = ?, underworld = ?, min_level = ?, max_level = ?, min_status = ?,
min_version = ?, instance_type = ?, max_players = ?, default_lockout_time = ?,
default_reenter_time = ?, default_reset_time = ?, group_zone_option = ?,
expansion_flag = ?, holiday_flag = ?, can_bind = ?, can_gate = ?, can_evac = ?,
city_zone = ?, always_loaded = ?, weather_allowed = ?
WHERE id = ?`,
"selectZoneConfig": `
SELECT id, name, file, description, safe_x, safe_y, safe_z, safe_heading, underworld,
min_level, max_level, min_status, min_version, instance_type, max_players,
default_lockout_time, default_reenter_time, default_reset_time, group_zone_option,
expansion_flag, holiday_flag, can_bind, can_gate, can_evac, city_zone, always_loaded,
weather_allowed
FROM zones WHERE id = ?`,
"selectSpawnLocations": `
SELECT id, x, y, z, heading, pitch, roll, spawn_type, respawn_time, expire_time,
expire_offset, conditions, conditional_value, spawn_percentage
FROM spawn_location_placement WHERE zone_id = ?
ORDER BY id`,
"selectSpawnLocation": `
SELECT id, x, y, z, heading, pitch, roll, spawn_type, respawn_time, expire_time,
expire_offset, conditions, conditional_value, spawn_percentage
FROM spawn_location_placement WHERE id = ?`,
"insertSpawnLocation": `
INSERT INTO spawn_location_placement
(x, y, z, heading, pitch, roll, spawn_type, respawn_time, expire_time,
expire_offset, conditions, conditional_value, spawn_percentage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id`,
"updateSpawnLocation": `
UPDATE spawn_location_placement SET
x = ?, y = ?, z = ?, heading = ?, pitch = ?, roll = ?, spawn_type = ?,
respawn_time = ?, expire_time = ?, expire_offset = ?, conditions = ?,
conditional_value = ?, spawn_percentage = ?
WHERE id = ?`,
"deleteSpawnLocation": `DELETE FROM spawn_location_placement WHERE id = ?`,
"selectSpawnEntries": `
SELECT id, spawn_type, spawn_entry_id, name, level, encounter_level, model, size,
hp, power, heroic, gender, race, adventure_class, tradeskill_class, attack_type,
min_level, max_level, encounter_type, show_name, targetable, show_level,
command_primary, command_secondary, loot_tier, min_gold, max_gold, harvest_type,
icon
FROM spawn_location_entry WHERE zone_id = ?
ORDER BY id`,
"selectNPCs": `
SELECT id, spawn_entry_id, name, level, encounter_level, model, size, hp, power,
heroic, gender, race, adventure_class, tradeskill_class, attack_type, min_level,
max_level, encounter_type, show_name, targetable, show_level, loot_tier,
min_gold, max_gold, aggro_radius, cast_percentage, randomize
FROM spawn_npcs WHERE zone_id = ?
ORDER BY id`,
"selectObjects": `
SELECT id, spawn_entry_id, name, model, size, device_id, icon, sound_name
FROM spawn_objects WHERE zone_id = ?
ORDER BY id`,
"selectWidgets": `
SELECT id, spawn_entry_id, name, model, widget_type, open_type, open_time,
close_time, open_sound, close_sound, open_graphic, close_graphic,
linked_spawn_id, action_spawn_id, house_id, include_location,
include_heading
FROM spawn_widgets WHERE zone_id = ?
ORDER BY id`,
"selectSigns": `
SELECT id, spawn_entry_id, name, model, sign_type, zone_id_destination,
widget_id, title, description, zone_x, zone_y, zone_z, zone_heading,
include_location, include_heading
FROM spawn_signs WHERE zone_id = ?
ORDER BY id`,
"selectGroundSpawns": `
SELECT id, spawn_entry_id, name, model, harvest_type, number_harvests,
max_number_harvests, collection_skill, respawn_timer
FROM spawn_ground_spawns WHERE zone_id = ?
ORDER BY id`,
"selectTransporters": `
SELECT id, type, display_name, message, destination_zone_id, destination_x,
destination_y, destination_z, destination_heading, cost, unique_id,
min_level, max_level, quest_req, quest_step_req, quest_complete,
map_x, map_y, expansion_flag, holiday_flag, min_client_version,
max_client_version, flight_path_id, mount_id, mount_red_color,
mount_green_color, mount_blue_color
FROM transporters WHERE zone_id = ?
ORDER BY id`,
"selectLocationGrids": `
SELECT id, grid_id, name, include_y, discovery
FROM location_grids WHERE zone_id = ?
ORDER BY id`,
"selectLocationGridLocations": `
SELECT location_id, x, y, z, name
FROM location_grid_locations WHERE grid_id = ?
ORDER BY location_id`,
"selectRevivePoints": `
SELECT id, zone_id, location_name, x, y, z, heading, always_included
FROM revive_points WHERE zone_id = ?
ORDER BY id`,
"selectSpawnGroups": `
SELECT group_id, location_id
FROM spawn_location_group WHERE zone_id = ?
ORDER BY group_id, location_id`,
"insertSpawnGroup": `
INSERT INTO spawn_location_group (group_id, location_id) VALUES (?, ?)`,
"deleteSpawnGroup": `
DELETE FROM spawn_location_group WHERE group_id = ?`,
}
for name, query := range statements {
stmt, err := zdb.db.Prepare(query)
if err != nil {
return fmt.Errorf("failed to prepare statement %s: %v", name, err)
}
zdb.queries[name] = stmt
}
return nil
}
func (zdb *ZoneDatabase) loadZoneConfiguration(zoneData *ZoneData) error {
stmt := zdb.queries["selectZoneConfig"]
if stmt == nil {
return fmt.Errorf("select zone config statement not prepared")
}
config := &ZoneConfiguration{}
err := stmt.QueryRow(zoneData.ZoneID).Scan(
&config.ZoneID,
&config.Name,
&config.File,
&config.Description,
&config.SafeX,
&config.SafeY,
&config.SafeZ,
&config.SafeHeading,
&config.Underworld,
&config.MinLevel,
&config.MaxLevel,
&config.MinStatus,
&config.MinVersion,
&config.InstanceType,
&config.MaxPlayers,
&config.DefaultLockoutTime,
&config.DefaultReenterTime,
&config.DefaultResetTime,
&config.GroupZoneOption,
&config.ExpansionFlag,
&config.HolidayFlag,
&config.CanBind,
&config.CanGate,
&config.CanEvac,
&config.CityZone,
&config.AlwaysLoaded,
&config.WeatherAllowed,
)
if err != nil {
return fmt.Errorf("failed to load zone configuration: %v", err)
}
zoneData.Configuration = config
return nil
}
func (zdb *ZoneDatabase) loadSpawnLocations(zoneData *ZoneData) error {
stmt := zdb.queries["selectSpawnLocations"]
if stmt == nil {
return fmt.Errorf("select spawn locations statement not prepared")
}
rows, err := stmt.Query(zoneData.ZoneID)
if err != nil {
return fmt.Errorf("failed to query spawn locations: %v", err)
}
defer rows.Close()
locations := make(map[int32]*SpawnLocation)
for rows.Next() {
location := &SpawnLocation{}
err := rows.Scan(
&location.ID,
&location.X,
&location.Y,
&location.Z,
&location.Heading,
&location.Pitch,
&location.Roll,
&location.SpawnType,
&location.RespawnTime,
&location.ExpireTime,
&location.ExpireOffset,
&location.Conditions,
&location.ConditionalValue,
&location.SpawnPercentage,
)
if err != nil {
return fmt.Errorf("failed to scan spawn location: %v", err)
}
locations[location.ID] = location
}
if err := rows.Err(); err != nil {
return fmt.Errorf("error iterating spawn locations: %v", err)
}
zoneData.SpawnLocations = locations
return nil
}
func (zdb *ZoneDatabase) loadSpawnEntries(zoneData *ZoneData) error {
// Similar implementation for spawn entries
zoneData.SpawnEntries = make(map[int32]*SpawnEntry)
return nil // TODO: Implement
}
func (zdb *ZoneDatabase) loadNPCs(zoneData *ZoneData) error {
// Similar implementation for NPCs
zoneData.NPCs = make(map[int32]*NPCTemplate)
return nil // TODO: Implement
}
func (zdb *ZoneDatabase) loadObjects(zoneData *ZoneData) error {
// Similar implementation for objects
zoneData.Objects = make(map[int32]*ObjectTemplate)
return nil // TODO: Implement
}
func (zdb *ZoneDatabase) loadWidgets(zoneData *ZoneData) error {
// Similar implementation for widgets
zoneData.Widgets = make(map[int32]*WidgetTemplate)
return nil // TODO: Implement
}
func (zdb *ZoneDatabase) loadSigns(zoneData *ZoneData) error {
// Similar implementation for signs
zoneData.Signs = make(map[int32]*SignTemplate)
return nil // TODO: Implement
}
func (zdb *ZoneDatabase) loadGroundSpawns(zoneData *ZoneData) error {
// Similar implementation for ground spawns
zoneData.GroundSpawns = make(map[int32]*GroundSpawnTemplate)
return nil // TODO: Implement
}
func (zdb *ZoneDatabase) loadTransporters(zoneData *ZoneData) error {
// Similar implementation for transporters
zoneData.Transporters = make(map[int32]*TransportDestination)
return nil // TODO: Implement
}
func (zdb *ZoneDatabase) loadLocationGrids(zoneData *ZoneData) error {
// Similar implementation for location grids
zoneData.LocationGrids = make(map[int32]*LocationGrid)
return nil // TODO: Implement
}
func (zdb *ZoneDatabase) loadRevivePoints(zoneData *ZoneData) error {
// Similar implementation for revive points
zoneData.RevivePoints = make(map[int32]*RevivePoint)
return nil // TODO: Implement
}
// ZoneData represents all data loaded for a zone
type ZoneData struct {
ZoneID int32
Configuration *ZoneConfiguration
SpawnLocations map[int32]*SpawnLocation
SpawnEntries map[int32]*SpawnEntry
NPCs map[int32]*NPCTemplate
Objects map[int32]*ObjectTemplate
Widgets map[int32]*WidgetTemplate
Signs map[int32]*SignTemplate
GroundSpawns map[int32]*GroundSpawnTemplate
Transporters map[int32]*TransportDestination
LocationGrids map[int32]*LocationGrid
RevivePoints map[int32]*RevivePoint
SpawnGroups map[int32][]int32
}
// ZoneConfiguration represents zone configuration from database
type ZoneConfiguration struct {
ZoneID int32
Name string
File string
Description string
SafeX float32
SafeY float32
SafeZ float32
SafeHeading float32
Underworld float32
MinLevel int16
MaxLevel int16
MinStatus int16
MinVersion int16
InstanceType int16
MaxPlayers int32
DefaultLockoutTime int32
DefaultReenterTime int32
DefaultResetTime int32
GroupZoneOption int8
ExpansionFlag int32
HolidayFlag int32
CanBind bool
CanGate bool
CanEvac bool
CityZone bool
AlwaysLoaded bool
WeatherAllowed bool
}
// Template types for database-loaded spawn data
type NPCTemplate struct {
ID int32
SpawnEntryID int32
Name string
Level int16
EncounterLevel int16
Model string
Size float32
HP int32
Power int32
Heroic int8
Gender int8
Race int16
AdventureClass int16
TradeskillClass int16
AttackType int8
MinLevel int16
MaxLevel int16
EncounterType int8
ShowName int8
Targetable int8
ShowLevel int8
LootTier int8
MinGold int32
MaxGold int32
AggroRadius float32
CastPercentage int8
Randomize bool
}
type ObjectTemplate struct {
ID int32
SpawnEntryID int32
Name string
Model string
Size float32
DeviceID int32
Icon int32
SoundName string
}
type WidgetTemplate struct {
ID int32
SpawnEntryID int32
Name string
Model string
WidgetType int8
OpenType int8
OpenTime int32
CloseTime int32
OpenSound string
CloseSound string
OpenGraphic string
CloseGraphic string
LinkedSpawnID int32
ActionSpawnID int32
HouseID int32
IncludeLocation bool
IncludeHeading bool
}
type SignTemplate struct {
ID int32
SpawnEntryID int32
Name string
Model string
SignType int8
ZoneIDDestination int32
WidgetID int32
Title string
Description string
ZoneX float32
ZoneY float32
ZoneZ float32
ZoneHeading float32
IncludeLocation bool
IncludeHeading bool
}
type GroundSpawnTemplate struct {
ID int32
SpawnEntryID int32
Name string
Model string
HarvestType string
NumberHarvests int8
MaxNumberHarvests int8
CollectionSkill string
RespawnTimer int32
}

512
internal/zone/interfaces.go Normal file
View File

@ -0,0 +1,512 @@
package zone
import (
"eq2emu/internal/items"
"eq2emu/internal/spawn"
)
// Client interface represents a connected player client
type Client interface {
GetID() uint32
GetCharacterID() int32
GetPlayerName() string
GetPlayer() Player
GetClientVersion() int32
IsLoadingZone() bool
SendPacket(data []byte) error
GetPosition() (x, y, z, heading float32, zoneID int32)
SetPosition(x, y, z, heading float32, zoneID int32)
GetSpawnRange() float32
IsInCombat() bool
CanSeeSpawn(spawn *spawn.Spawn) bool
GetLanguageID() int32
GetLanguageSkill(languageID int32) int32
}
// Player interface represents the player entity in the game world
type Player interface {
GetID() uint32
GetCharacterID() int32
GetName() string
GetLevel() int16
GetRace() int16
GetClass() int16
GetPosition() (x, y, z, heading float32)
SetPosition(x, y, z, heading float32)
GetZoneID() int32
SetZoneID(zoneID int32)
IsAlive() bool
GetHP() int32
GetMaxHP() int32
GetPower() int32
GetMaxPower() int32
GetSkillValue(skillID int32) int16
AddExperience(amount int32, skillID int32)
IsTracking() bool
GetTrackingType() int8
GetTrackingDistance() float32
CanCarryItems(count int) bool
AddItems(items []*items.Item) error
AddCoins(amount int32) error
SendMessage(message string) error
GetGroupID() int32
GetRaidID() int32
IsInGroup() bool
IsInRaid() bool
GetVisibleSpawns() map[int32]*spawn.Spawn
AddVisibleSpawn(spawn *spawn.Spawn)
RemoveVisibleSpawn(spawnID int32)
GetFactionValue(factionID int32) int32
SetFactionValue(factionID int32, value int32)
GetCompletedQuests() map[int32]bool
HasQuestCompleted(questID int32) bool
}
// NPC interface represents a non-player character
type NPC interface {
GetID() int32
GetDatabaseID() int32
GetName() string
GetLevel() int16
GetRace() int16
GetClass() int16
GetPosition() (x, y, z, heading float32)
SetPosition(x, y, z, heading float32)
GetMaxHP() int32
GetHP() int32
SetHP(hp int32)
GetMaxPower() int32
GetPower() int32
SetPower(power int32)
IsAlive() bool
GetLootTableID() int32
GetFactionID() int32
GetSkills() map[int32]int16
GetEquipment() map[int32]int32
GetBrain() NPCBrain
SetBrain(brain NPCBrain)
GetAggroList() map[uint32]int32
AddAggro(playerID uint32, amount int32)
RemoveAggro(playerID uint32)
GetMostHated() uint32
IsInCombat() bool
CanAttack(target *spawn.Spawn) bool
GetMovementLocations() []*MovementLocation
SetMovementLocations(locations []*MovementLocation)
GetRespawnTime() int32
SetRespawnTime(seconds int32)
GetSpawnGroupID() int32
SetSpawnGroupID(groupID int32)
GetRandomizedFeatures() map[string]interface{}
SetRandomizedFeatures(features map[string]interface{})
}
// Object interface represents an interactive world object
type Object interface {
GetID() int32
GetDatabaseID() int32
GetName() string
GetPosition() (x, y, z, heading float32)
SetPosition(x, y, z, heading float32)
GetAppearanceID() int32
GetSize() float32
GetDeviceID() int32
GetCommands() []string
CanInteract(player Player) bool
OnInteract(player Player, command string) error
Copy() Object
}
// Widget interface represents interactive widgets like doors and lifts
type Widget interface {
GetID() int32
GetDatabaseID() int32
GetName() string
GetPosition() (x, y, z, heading float32)
SetPosition(x, y, z, heading float32)
GetAppearanceID() int32
GetWidgetType() int8
IsOpen() bool
SetOpen(open bool)
GetOpenTime() int32
GetCloseTime() int32
GetLinkedSpawnID() int32
GetActionSpawnID() int32
GetHouseID() int32
CanInteract(player Player) bool
OnInteract(player Player) error
Copy() Widget
}
// Sign interface represents readable signs in the world
type Sign interface {
GetID() int32
GetDatabaseID() int32
GetName() string
GetPosition() (x, y, z, heading float32)
SetPosition(x, y, z, heading float32)
GetAppearanceID() int32
GetTitle() string
GetDescription() string
GetSignType() int8
GetZoneID() int32
GetWidgetID() int32
CanRead(player Player) bool
OnRead(player Player) error
Copy() Sign
}
// GroundSpawn interface represents harvestable ground spawns
type GroundSpawn interface {
GetID() int32
GetDatabaseID() int32
GetName() string
GetPosition() (x, y, z, heading float32)
SetPosition(x, y, z, heading float32)
GetAppearanceID() int32
GetHarvestType() int8
GetNumAttempts() int8
GetMaxAttempts() int8
GetRespawnTime() int32
GetSkillRequired() int16
GetLevelRequired() int16
CanHarvest(player Player) bool
OnHarvest(player Player) error
GetRareTable() int32
GetBonusTable() int32
Copy() GroundSpawn
}
// NPCBrain interface represents NPC AI behavior
type NPCBrain interface {
Think(npc NPC, zone *ZoneServer) error
OnAggro(npc NPC, attacker *spawn.Spawn) error
OnDeath(npc NPC, killer *spawn.Spawn) error
OnSpawn(npc NPC) error
OnDespawn(npc NPC) error
GetBrainType() int8
}
// IPathfinder interface for pathfinding systems
type IPathfinder interface {
CalculatePath(startX, startY, startZ, endX, endY, endZ float32) ([]*PathNode, error)
GetRandomLocation(x, y, z, radius float32) (newX, newY, newZ float32, err error)
IsLocationAccessible(x, y, z float32) bool
GetClosestPoint(x, y, z float32) (closestX, closestY, closestZ float32)
}
// Map interface represents zone collision and height data
type Map interface {
FindBestZ(x, y, z float32) (bestZ float32, found bool)
FindClosestZ(x, y, z float32) float32
CheckLoS(x1, y1, z1, x2, y2, z2 float32) bool
DoCollisionCheck(x1, y1, z1, x2, y2, z2 float32) (hit bool, hitX, hitY, hitZ float32)
LineIntersectsZone(x1, y1, z1, x2, y2, z2 float32) bool
IsLoaded() bool
GetMapVersion() int32
GetBounds() (minX, minY, minZ, maxX, maxY, maxZ float32)
}
// RegionMap interface for zone region management
type RegionMap interface {
InWater(x, y, z float32) (inWater bool, waterType int8)
InLava(x, y, z float32) bool
InPvP(x, y, z float32) bool
InZoneLine(x, y, z float32) (inZoneLine bool, zoneLineID int32)
GetRegionType(x, y, z, heading float32) int32
GetEnvironmentalDamage(x, y, z float32) int32
IsValidLocation(x, y, z float32) bool
}
// SpellProcess interface for spell processing
type SpellProcess interface {
ProcessSpell(spell Spell, caster Entity, target *spawn.Spawn) error
InterruptSpell(caster Entity, interruptor *spawn.Spawn) error
AddSpellTimer(caster Entity, spellID int32, duration int32) error
RemoveSpellTimer(caster Entity, spellID int32) error
GetActiveSpells(entity Entity) map[int32]*ActiveSpell
ProcessSpellEffects() error
LockSpells(entity Entity)
UnlockSpells(entity Entity)
IsSpellLocked(entity Entity) bool
}
// TradeskillManager interface for tradeskill processing
type TradeskillManager interface {
StartCrafting(player Player, recipeID int32) error
ProcessCrafting(player Player) error
CompleteCrafting(player Player) error
CancelCrafting(player Player) error
GetRecipe(recipeID int32) Recipe
GetPlayerRecipes(playerID uint32) []int32
CanCraftRecipe(player Player, recipeID int32) bool
}
// Entity interface represents any combatable entity
type Entity interface {
GetID() uint32
GetName() string
GetLevel() int16
GetRace() int16
GetClass() int16
GetPosition() (x, y, z, heading float32)
SetPosition(x, y, z, heading float32)
GetHP() int32
GetMaxHP() int32
SetHP(hp int32)
GetPower() int32
GetMaxPower() int32
SetPower(power int32)
IsAlive() bool
TakeDamage(amount int32, attacker Entity, damageType int8) error
Heal(amount int32, healer Entity) error
GetResists() map[int8]int16
GetStats() map[int8]int32
GetSkillValue(skillID int32) int16
GetSpellEffects() map[int32]*SpellEffect
AddSpellEffect(effect *SpellEffect) error
RemoveSpellEffect(effectID int32) error
CanCast() bool
StartCasting(spell Spell) error
InterruptCasting() error
}
// Spell interface represents a castable spell
type Spell interface {
GetID() int32
GetName() string
GetDescription() string
GetCastTime() int16
GetRecoveryTime() int16
GetRecastTime() int32
GetRange() float32
GetRadius() float32
GetPowerCost() int16
GetHPCost() int16
GetTargetType() int8
GetSpellType() int8
GetLevel() int16
GetTier() int8
GetIcon() int16
GetEffects() []*SpellEffect
CanCast(caster Entity, target *spawn.Spawn) bool
GetRequiredComponents() map[int32]int16
}
// SpellEffect interface represents an active spell effect
type SpellEffect interface {
GetID() int32
GetSpellID() int32
GetEffectType() int8
GetSubType() int8
GetValue() float32
GetDuration() int32
GetRemainingTime() int32
GetCasterID() uint32
GetTargetID() uint32
IsExpired() bool
Apply(target Entity) error
Remove(target Entity) error
Tick(target Entity) error
}
// ActiveSpell represents a spell being cast or maintained
type ActiveSpell interface {
GetSpell() Spell
GetCaster() Entity
GetTargets() []*spawn.Spawn
GetCastTime() int32
GetRemainingCastTime() int32
IsChanneling() bool
IsMaintained() bool
GetEndTime() int64
}
// Recipe interface for tradeskill recipes
type Recipe interface {
GetID() int32
GetName() string
GetDescription() string
GetSkillID() int32
GetRequiredLevel() int16
GetDifficulty() int16
GetComponents() map[int32]int16
GetProducts() map[int32]int16
GetExperience() int32
}
// MovementLocation represents a movement waypoint for NPCs
type MovementLocation struct {
X float32
Y float32
Z float32
Heading float32
Speed float32
Delay int32
MovementType int8
}
// PathNode represents a single node in a calculated path
type PathNode struct {
X float32
Y float32
Z float32
}
// SpawnLocation represents a spawn point configuration
type SpawnLocation struct {
ID int32
X float32
Y float32
Z float32
Heading float32
Pitch float32
Roll float32
SpawnType int8
SpawnEntry *SpawnEntry
RespawnTime int32
ExpireTime int32
ExpireOffset int32
Conditions int8
ConditionalValue int32
SpawnPercentage float32
Groups []int32
}
// SpawnEntry contains the template data for spawns
type SpawnEntry struct {
ID int32
SpawnType int8
SpawnEntryID int32
Name string
Level int16
EncounterLevel int16
Model string
Size float32
HP int32
Power int32
Heroic int8
Gender int8
Race int16
AdventureClass int16
TradeskillClass int16
AttackType int8
MinLevel int16
MaxLevel int16
EncounterType int8
ShowName int8
Targetable int8
ShowLevel int8
Command string
LootTier int8
MinGold int32
MaxGold int32
HarvestType string
Icon int32
}
// EntityCommand represents an available command for an entity
type EntityCommand struct {
ID int32
Name string
Distance float32
ErrorText string
CastTime int16
SpellVisual int32
Command string
DisplayText string
}
// LootTable represents a loot table configuration
type LootTable struct {
ID int32
Name string
MinCoin int32
MaxCoin int32
MaxLootItems int16
LootDropProbability float32
CoinProbability float32
Drops []*LootDrop
}
// LootDrop represents an individual item drop in a loot table
type LootDrop struct {
LootTableID int32
ItemID int32
ItemCharges int16
EquipItem bool
Probability float32
NoDropQuestCompletedID int32
}
// GlobalLoot represents global loot rules
type GlobalLoot struct {
Type string
TableID int32
MinLevel int8
MaxLevel int8
RaceID int16
ZoneID int32
LootTier int32
}
// TransportDestination represents a transport destination
type TransportDestination struct {
ID int32
Type int8
Name string
Message string
DestinationZoneID int32
DestinationX float32
DestinationY float32
DestinationZ float32
DestinationHeading float32
Cost int32
UniqueID int32
MinLevel int8
MaxLevel int8
QuestRequired int32
QuestStepRequired int16
QuestCompleted int32
MapX int32
MapY int32
ExpansionFlag int32
HolidayFlag int32
MinClientVersion int32
MaxClientVersion int32
FlightPathID int32
MountID int16
MountRedColor int8
MountGreenColor int8
MountBlueColor int8
}
// LocationTransportDestination represents a location-based transport trigger
type LocationTransportDestination struct {
ZoneID int32
Message string
TriggerX float32
TriggerY float32
TriggerZ float32
TriggerRadius float32
DestinationZoneID int32
DestinationX float32
DestinationY float32
DestinationZ float32
DestinationHeading float32
Cost int32
UniqueID int32
ForceZone bool
}
// Location represents a discoverable location
type Location struct {
ID int32
Name string
X float32
Y float32
Z float32
}
// Item placeholder - should import from items package
type Item = items.Item

View File

@ -0,0 +1,627 @@
package zone
import (
"fmt"
"log"
"sync"
"time"
"eq2emu/internal/spawn"
)
// MobMovementManager handles movement for all NPCs and entities in the zone
type MobMovementManager struct {
zone *ZoneServer
movementSpawns map[int32]*MovementState
commandQueue map[int32][]*MovementCommand
stuckSpawns map[int32]*StuckInfo
processedSpawns map[int32]bool
lastUpdate time.Time
isProcessing bool
mutex sync.RWMutex
}
// MovementState tracks the current movement state of a spawn
type MovementState struct {
SpawnID int32
CurrentCommand *MovementCommand
CommandQueue []*MovementCommand
LastPosition *Position
LastMoveTime time.Time
IsMoving bool
IsStuck bool
StuckCount int
Speed float32
MovementMode int8
TargetPosition *Position
TargetHeading float32
PathNodes []*PathNode
CurrentNodeIndex int
PauseTime int32
PauseUntil time.Time
}
// MovementCommand represents a movement instruction
type MovementCommand struct {
Type int8
TargetX float32
TargetY float32
TargetZ float32
TargetHeading float32
Speed float32
MovementMode int8
StuckBehavior int8
MaxDistance float32
CompletionFunc func(*spawn.Spawn, bool) // Called when command completes (success bool)
}
// StuckInfo tracks stuck detection for spawns
type StuckInfo struct {
Position *Position
StuckCount int
LastStuckTime time.Time
Behavior int8
AttemptCount int
}
// NewMobMovementManager creates a new movement manager for the zone
func NewMobMovementManager(zone *ZoneServer) *MobMovementManager {
return &MobMovementManager{
zone: zone,
movementSpawns: make(map[int32]*MovementState),
commandQueue: make(map[int32][]*MovementCommand),
stuckSpawns: make(map[int32]*StuckInfo),
processedSpawns: make(map[int32]bool),
lastUpdate: time.Now(),
}
}
// Process handles movement processing for all managed spawns
func (mm *MobMovementManager) Process() error {
mm.mutex.Lock()
defer mm.mutex.Unlock()
if mm.isProcessing {
return nil // Already processing
}
mm.isProcessing = true
defer func() { mm.isProcessing = false }()
now := time.Now()
deltaTime := now.Sub(mm.lastUpdate).Seconds()
mm.lastUpdate = now
// Process each spawn with movement
for spawnID, state := range mm.movementSpawns {
spawn := mm.zone.GetSpawn(spawnID)
if spawn == nil {
// Spawn no longer exists, remove from tracking
delete(mm.movementSpawns, spawnID)
delete(mm.commandQueue, spawnID)
delete(mm.stuckSpawns, spawnID)
continue
}
// Skip if spawn is paused
if !state.PauseUntil.IsZero() && now.Before(state.PauseUntil) {
continue
}
// Process current command
if err := mm.processSpawnMovement(spawn, state, float32(deltaTime)); err != nil {
log.Printf("%s Error processing movement for spawn %d: %v", LogPrefixMovement, spawnID, err)
}
}
return nil
}
// AddMovementSpawn adds a spawn to movement tracking
func (mm *MobMovementManager) AddMovementSpawn(spawnID int32) {
mm.mutex.Lock()
defer mm.mutex.Unlock()
if _, exists := mm.movementSpawns[spawnID]; exists {
return // Already tracking
}
spawn := mm.zone.GetSpawn(spawnID)
if spawn == nil {
return
}
x, y, z, heading := spawn.GetPosition()
mm.movementSpawns[spawnID] = &MovementState{
SpawnID: spawnID,
LastPosition: NewPosition(x, y, z, heading),
LastMoveTime: time.Now(),
Speed: DefaultRunSpeed,
MovementMode: MovementModeRun,
}
mm.commandQueue[spawnID] = make([]*MovementCommand, 0)
log.Printf("%s Added spawn %d to movement tracking", LogPrefixMovement, spawnID)
}
// RemoveMovementSpawn removes a spawn from movement tracking
func (mm *MobMovementManager) RemoveMovementSpawn(spawnID int32) {
mm.mutex.Lock()
defer mm.mutex.Unlock()
delete(mm.movementSpawns, spawnID)
delete(mm.commandQueue, spawnID)
delete(mm.stuckSpawns, spawnID)
delete(mm.processedSpawns, spawnID)
log.Printf("%s Removed spawn %d from movement tracking", LogPrefixMovement, spawnID)
}
// MoveTo commands a spawn to move to the specified position
func (mm *MobMovementManager) MoveTo(spawnID int32, x, y, z float32, speed float32) error {
command := &MovementCommand{
Type: MovementCommandMoveTo,
TargetX: x,
TargetY: y,
TargetZ: z,
Speed: speed,
MovementMode: MovementModeRun,
StuckBehavior: StuckBehaviorEvade,
}
return mm.QueueCommand(spawnID, command)
}
// SwimTo commands a spawn to swim to the specified position
func (mm *MobMovementManager) SwimTo(spawnID int32, x, y, z float32, speed float32) error {
command := &MovementCommand{
Type: MovementCommandSwimTo,
TargetX: x,
TargetY: y,
TargetZ: z,
Speed: speed,
MovementMode: MovementModeSwim,
StuckBehavior: StuckBehaviorWarp,
}
return mm.QueueCommand(spawnID, command)
}
// TeleportTo instantly moves a spawn to the specified position
func (mm *MobMovementManager) TeleportTo(spawnID int32, x, y, z, heading float32) error {
command := &MovementCommand{
Type: MovementCommandTeleportTo,
TargetX: x,
TargetY: y,
TargetZ: z,
TargetHeading: heading,
Speed: 0, // Instant
MovementMode: MovementModeRun,
}
return mm.QueueCommand(spawnID, command)
}
// RotateTo commands a spawn to rotate to the specified heading
func (mm *MobMovementManager) RotateTo(spawnID int32, heading float32, speed float32) error {
command := &MovementCommand{
Type: MovementCommandRotateTo,
TargetHeading: heading,
Speed: speed,
MovementMode: MovementModeRun,
}
return mm.QueueCommand(spawnID, command)
}
// StopMoving commands a spawn to stop all movement
func (mm *MobMovementManager) StopMoving(spawnID int32) error {
command := &MovementCommand{
Type: MovementCommandStop,
}
return mm.QueueCommand(spawnID, command)
}
// EvadeCombat commands a spawn to evade and return to its spawn point
func (mm *MobMovementManager) EvadeCombat(spawnID int32) error {
spawn := mm.zone.GetSpawn(spawnID)
if spawn == nil {
return fmt.Errorf("spawn %d not found", spawnID)
}
// Get spawn's original position (would need to be stored somewhere)
// For now, use current position as placeholder
x, y, z, heading := spawn.GetPosition()
command := &MovementCommand{
Type: MovementCommandEvadeCombat,
TargetX: x,
TargetY: y,
TargetZ: z,
TargetHeading: heading,
Speed: DefaultRunSpeed * 1.5, // Faster when evading
MovementMode: MovementModeRun,
StuckBehavior: StuckBehaviorWarp,
}
return mm.QueueCommand(spawnID, command)
}
// QueueCommand adds a movement command to the spawn's queue
func (mm *MobMovementManager) QueueCommand(spawnID int32, command *MovementCommand) error {
mm.mutex.Lock()
defer mm.mutex.Unlock()
// Ensure spawn is being tracked
if _, exists := mm.movementSpawns[spawnID]; !exists {
mm.AddMovementSpawn(spawnID)
}
// Add command to queue
if _, exists := mm.commandQueue[spawnID]; !exists {
mm.commandQueue[spawnID] = make([]*MovementCommand, 0)
}
mm.commandQueue[spawnID] = append(mm.commandQueue[spawnID], command)
log.Printf("%s Queued movement command %d for spawn %d", LogPrefixMovement, command.Type, spawnID)
return nil
}
// ClearCommands clears all queued commands for a spawn
func (mm *MobMovementManager) ClearCommands(spawnID int32) {
mm.mutex.Lock()
defer mm.mutex.Unlock()
if queue, exists := mm.commandQueue[spawnID]; exists {
mm.commandQueue[spawnID] = queue[:0] // Clear slice but keep capacity
}
if state, exists := mm.movementSpawns[spawnID]; exists {
state.CurrentCommand = nil
state.IsMoving = false
state.PathNodes = nil
state.CurrentNodeIndex = 0
}
}
// IsMoving returns whether a spawn is currently moving
func (mm *MobMovementManager) IsMoving(spawnID int32) bool {
mm.mutex.RLock()
defer mm.mutex.RUnlock()
if state, exists := mm.movementSpawns[spawnID]; exists {
return state.IsMoving
}
return false
}
// GetMovementState returns the current movement state for a spawn
func (mm *MobMovementManager) GetMovementState(spawnID int32) *MovementState {
mm.mutex.RLock()
defer mm.mutex.RUnlock()
if state, exists := mm.movementSpawns[spawnID]; exists {
// Return a copy to avoid race conditions
return &MovementState{
SpawnID: state.SpawnID,
IsMoving: state.IsMoving,
IsStuck: state.IsStuck,
Speed: state.Speed,
MovementMode: state.MovementMode,
CurrentNodeIndex: state.CurrentNodeIndex,
}
}
return nil
}
// Private methods
func (mm *MobMovementManager) processSpawnMovement(spawn *spawn.Spawn, state *MovementState, deltaTime float32) error {
// Get next command if not currently executing one
if state.CurrentCommand == nil {
state.CurrentCommand = mm.getNextCommand(state.SpawnID)
if state.CurrentCommand == nil {
state.IsMoving = false
return nil // No commands to process
}
}
// Process current command
completed, err := mm.processMovementCommand(spawn, state, deltaTime)
if err != nil {
return err
}
// If command completed, call completion function and get next command
if completed {
if state.CurrentCommand.CompletionFunc != nil {
state.CurrentCommand.CompletionFunc(spawn, true)
}
state.CurrentCommand = nil
state.IsMoving = false
}
return nil
}
func (mm *MobMovementManager) processMovementCommand(spawn *spawn.Spawn, state *MovementState, deltaTime float32) (bool, error) {
command := state.CurrentCommand
if command == nil {
return true, nil
}
switch command.Type {
case MovementCommandMoveTo:
return mm.processMoveTo(spawn, state, command, deltaTime)
case MovementCommandSwimTo:
return mm.processSwimTo(spawn, state, command, deltaTime)
case MovementCommandTeleportTo:
return mm.processTeleportTo(spawn, state, command)
case MovementCommandRotateTo:
return mm.processRotateTo(spawn, state, command, deltaTime)
case MovementCommandStop:
return mm.processStop(spawn, state, command)
case MovementCommandEvadeCombat:
return mm.processEvadeCombat(spawn, state, command, deltaTime)
default:
return true, fmt.Errorf("unknown movement command type: %d", command.Type)
}
}
func (mm *MobMovementManager) processMoveTo(spawn *spawn.Spawn, state *MovementState, command *MovementCommand, deltaTime float32) (bool, error) {
currentX, currentY, currentZ, currentHeading := spawn.GetPosition()
// Calculate distance to target
distanceToTarget := Distance3D(currentX, currentY, currentZ, command.TargetX, command.TargetY, command.TargetZ)
// Check if we've reached the target
if distanceToTarget <= 0.5 { // Close enough threshold
return true, nil
}
// Check for stuck condition
if mm.checkStuck(spawn, state) {
return mm.handleStuck(spawn, state, command)
}
// Calculate movement
speed := command.Speed
if speed <= 0 {
speed = state.Speed
}
maxMove := speed * deltaTime
if maxMove > distanceToTarget {
maxMove = distanceToTarget
}
// Calculate direction
dx := command.TargetX - currentX
dy := command.TargetY - currentY
dz := command.TargetZ - currentZ
// Normalize direction
distance := Distance3D(0, 0, 0, dx, dy, dz)
if distance > 0 {
dx /= distance
dy /= distance
dz /= distance
}
// Calculate new position
newX := currentX + dx*maxMove
newY := currentY + dy*maxMove
newZ := currentZ + dz*maxMove
// Calculate heading to target
newHeading := CalculateHeading(currentX, currentY, command.TargetX, command.TargetY)
// Update spawn position
spawn.SetPosition(newX, newY, newZ, newHeading)
// Update state
state.LastPosition.Set(newX, newY, newZ, newHeading)
state.LastMoveTime = time.Now()
state.IsMoving = true
// Mark spawn as changed for client updates
mm.zone.markSpawnChanged(spawn.GetID())
return false, nil // Not completed yet
}
func (mm *MobMovementManager) processSwimTo(spawn *spawn.Spawn, state *MovementState, command *MovementCommand, deltaTime float32) (bool, error) {
// Similar to MoveTo but with different movement mode
return mm.processMoveTo(spawn, state, command, deltaTime)
}
func (mm *MobMovementManager) processTeleportTo(spawn *spawn.Spawn, state *MovementState, command *MovementCommand) (bool, error) {
// Instant teleport
spawn.SetPosition(command.TargetX, command.TargetY, command.TargetZ, command.TargetHeading)
// Update state
state.LastPosition.Set(command.TargetX, command.TargetY, command.TargetZ, command.TargetHeading)
state.LastMoveTime = time.Now()
state.IsMoving = false
// Mark spawn as changed
mm.zone.markSpawnChanged(spawn.GetID())
log.Printf("%s Teleported spawn %d to (%.2f, %.2f, %.2f)",
LogPrefixMovement, spawn.GetID(), command.TargetX, command.TargetY, command.TargetZ)
return true, nil // Completed immediately
}
func (mm *MobMovementManager) processRotateTo(spawn *spawn.Spawn, state *MovementState, command *MovementCommand, deltaTime float32) (bool, error) {
currentX, currentY, currentZ, currentHeading := spawn.GetPosition()
// Calculate heading difference
headingDiff := HeadingDifference(currentHeading, command.TargetHeading)
// Check if we've reached the target heading
if abs(headingDiff) <= 1.0 { // Close enough threshold
spawn.SetPosition(currentX, currentY, currentZ, command.TargetHeading)
mm.zone.markSpawnChanged(spawn.GetID())
return true, nil
}
// Calculate rotation speed
rotationSpeed := command.Speed
if rotationSpeed <= 0 {
rotationSpeed = 90.0 // Default rotation speed in heading units per second
}
maxRotation := rotationSpeed * deltaTime
// Determine rotation direction and amount
var rotation float32
if abs(headingDiff) <= maxRotation {
rotation = headingDiff
} else if headingDiff > 0 {
rotation = maxRotation
} else {
rotation = -maxRotation
}
// Apply rotation
newHeading := NormalizeHeading(currentHeading + rotation)
spawn.SetPosition(currentX, currentY, currentZ, newHeading)
// Update state
state.LastPosition.Heading = newHeading
state.LastMoveTime = time.Now()
// Mark spawn as changed
mm.zone.markSpawnChanged(spawn.GetID())
return false, nil // Not completed yet
}
func (mm *MobMovementManager) processStop(spawn *spawn.Spawn, state *MovementState, command *MovementCommand) (bool, error) {
state.IsMoving = false
state.PathNodes = nil
state.CurrentNodeIndex = 0
log.Printf("%s Stopped movement for spawn %d", LogPrefixMovement, spawn.GetID())
return true, nil
}
func (mm *MobMovementManager) processEvadeCombat(spawn *spawn.Spawn, state *MovementState, command *MovementCommand, deltaTime float32) (bool, error) {
// Similar to MoveTo but with evade behavior
return mm.processMoveTo(spawn, state, command, deltaTime)
}
func (mm *MobMovementManager) getNextCommand(spawnID int32) *MovementCommand {
if queue, exists := mm.commandQueue[spawnID]; exists && len(queue) > 0 {
command := queue[0]
mm.commandQueue[spawnID] = queue[1:] // Remove first command
return command
}
return nil
}
func (mm *MobMovementManager) checkStuck(spawn *spawn.Spawn, state *MovementState) bool {
currentX, currentY, currentZ, _ := spawn.GetPosition()
// Check if spawn has moved significantly since last update
if state.LastPosition != nil {
distance := Distance3D(currentX, currentY, currentZ, state.LastPosition.X, state.LastPosition.Y, state.LastPosition.Z)
if distance < 0.1 && time.Since(state.LastMoveTime) > time.Second*2 {
// Spawn hasn't moved much in 2 seconds
return true
}
}
return false
}
func (mm *MobMovementManager) handleStuck(spawn *spawn.Spawn, state *MovementState, command *MovementCommand) (bool, error) {
spawnID := spawn.GetID()
// Get or create stuck info
stuckInfo, exists := mm.stuckSpawns[spawnID]
if !exists {
currentX, currentY, currentZ, currentHeading := spawn.GetPosition()
stuckInfo = &StuckInfo{
Position: NewPosition(currentX, currentY, currentZ, currentHeading),
StuckCount: 0,
LastStuckTime: time.Now(),
Behavior: command.StuckBehavior,
}
mm.stuckSpawns[spawnID] = stuckInfo
}
stuckInfo.StuckCount++
stuckInfo.AttemptCount++
log.Printf("%s Spawn %d is stuck (count: %d)", LogPrefixMovement, spawnID, stuckInfo.StuckCount)
switch stuckInfo.Behavior {
case StuckBehaviorNone:
return true, nil // Give up
case StuckBehaviorRun:
// Try to move around the obstacle
return mm.handleStuckWithRun(spawn, state, command, stuckInfo)
case StuckBehaviorWarp:
// Teleport to target
return mm.handleStuckWithWarp(spawn, state, command, stuckInfo)
case StuckBehaviorEvade:
// Return to spawn point
return mm.handleStuckWithEvade(spawn, state, command, stuckInfo)
default:
return true, nil // Unknown behavior, give up
}
}
func (mm *MobMovementManager) handleStuckWithRun(spawn *spawn.Spawn, state *MovementState, command *MovementCommand, stuckInfo *StuckInfo) (bool, error) {
if stuckInfo.AttemptCount > 5 {
return true, nil // Give up after 5 attempts
}
// Try a slightly different path
currentX, currentY, currentZ, _ := spawn.GetPosition()
// Add some randomness to the movement
offsetX := float32((time.Now().UnixNano()%100 - 50)) / 50.0 * 2.0
offsetY := float32((time.Now().UnixNano()%100 - 50)) / 50.0 * 2.0
newTargetX := command.TargetX + offsetX
newTargetY := command.TargetY + offsetY
// Update command with new target
command.TargetX = newTargetX
command.TargetY = newTargetY
log.Printf("%s Trying alternate path for stuck spawn %d", LogPrefixMovement, spawn.GetID())
return false, nil // Continue with modified command
}
func (mm *MobMovementManager) handleStuckWithWarp(spawn *spawn.Spawn, state *MovementState, command *MovementCommand, stuckInfo *StuckInfo) (bool, error) {
// Teleport directly to target
spawn.SetPosition(command.TargetX, command.TargetY, command.TargetZ, command.TargetHeading)
mm.zone.markSpawnChanged(spawn.GetID())
log.Printf("%s Warped stuck spawn %d to target", LogPrefixMovement, spawn.GetID())
return true, nil // Command completed
}
func (mm *MobMovementManager) handleStuckWithEvade(spawn *spawn.Spawn, state *MovementState, command *MovementCommand, stuckInfo *StuckInfo) (bool, error) {
// Return to original position (evade)
if stuckInfo.Position != nil {
spawn.SetPosition(stuckInfo.Position.X, stuckInfo.Position.Y, stuckInfo.Position.Z, stuckInfo.Position.Heading)
mm.zone.markSpawnChanged(spawn.GetID())
log.Printf("%s Evaded stuck spawn %d to original position", LogPrefixMovement, spawn.GetID())
}
return true, nil // Command completed
}

View File

@ -0,0 +1,49 @@
package pathfinder
// Default pathfinding configuration values
const (
// Default flag costs (matching C++ implementation)
DefaultFlagCost0 = 1.0 // Normal
DefaultFlagCost1 = 3.0 // Water
DefaultFlagCost2 = 5.0 // Lava
DefaultFlagCost3 = 1.0 // Zone line
DefaultFlagCost4 = 2.0 // PvP
DefaultFlagCost5 = 2.0 // Slime
DefaultFlagCost6 = 4.0 // Ice
DefaultFlagCost7 = 1.0 // VWater
DefaultFlagCost8 = 0.1 // General area
DefaultFlagCost9 = 0.1 // Portal
// Default pathfinding parameters
DefaultStepSize = 10.0
DefaultOffset = 3.25
// Pathfinding limits
MaxPathNodes = 1000 // Maximum nodes in a single path
MaxPathDistance = 10000 // Maximum path distance
PathfindingTimeout = 5000 // Pathfinding timeout in milliseconds
// Random location parameters
RandomLocationAttempts = 50 // Max attempts to find random location
RandomLocationRadius = 100.0 // Search radius for random locations
// Performance constants
PathCacheSize = 1000 // Maximum cached paths
PathCacheExpiryMs = 30000 // Path cache expiry in milliseconds
StatsUpdateInterval = 1000 // Stats update interval in milliseconds
)
// Pathfinding backend types
const (
BackendTypeNull = "null"
BackendTypeNavmesh = "navmesh"
BackendTypeWaypoint = "waypoint"
BackendTypeDetour = "detour"
)
// Path validation constants
const (
MinPathDistance = 0.1 // Minimum distance for valid path
MaxPathNodeDistance = 50.0 // Maximum distance between path nodes
PathSmoothTolerance = 2.0 // Tolerance for path smoothing
)

View File

@ -0,0 +1,86 @@
package pathfinder
// PathfinderZoneIntegration defines the interface for zone integration
type PathfinderZoneIntegration interface {
// GetZoneName returns the zone name
GetZoneName() string
// IsValidPosition checks if a position is valid for pathfinding
IsValidPosition(position [3]float32) bool
// GetGroundZ returns the ground Z coordinate at the given X,Y position
GetGroundZ(x, y float32) float32
// IsInWater checks if a position is in water
IsInWater(position [3]float32) bool
// IsInLava checks if a position is in lava
IsInLava(position [3]float32) bool
// IsInPvP checks if a position is in a PvP area
IsInPvP(position [3]float32) bool
}
// PathfinderClientIntegration defines the interface for client notifications
type PathfinderClientIntegration interface {
// NotifyPathGenerated notifies clients about generated paths (for debugging)
NotifyPathGenerated(path *Path, clientID int32)
// SendPathUpdate sends path updates to clients
SendPathUpdate(spawnID int32, path *Path)
}
// PathfinderStatistics defines the interface for statistics collection
type PathfinderStatistics interface {
// RecordPathRequest records a pathfinding request
RecordPathRequest(duration float64, success bool, partial bool)
// GetPathfindingStats returns current statistics
GetPathfindingStats() *PathfindingStats
// ResetPathfindingStats resets all statistics
ResetPathfindingStats()
}
// PathfindingAdapter provides integration with the zone system
type PathfindingAdapter struct {
zoneIntegration PathfinderZoneIntegration
clientIntegration PathfinderClientIntegration
statistics PathfinderStatistics
}
// NewPathfindingAdapter creates a new pathfinding adapter
func NewPathfindingAdapter(zone PathfinderZoneIntegration, client PathfinderClientIntegration, stats PathfinderStatistics) *PathfindingAdapter {
return &PathfindingAdapter{
zoneIntegration: zone,
clientIntegration: client,
statistics: stats,
}
}
// GetZoneIntegration returns the zone integration interface
func (pa *PathfindingAdapter) GetZoneIntegration() PathfinderZoneIntegration {
return pa.zoneIntegration
}
// GetClientIntegration returns the client integration interface
func (pa *PathfindingAdapter) GetClientIntegration() PathfinderClientIntegration {
return pa.clientIntegration
}
// GetStatistics returns the statistics interface
func (pa *PathfindingAdapter) GetStatistics() PathfinderStatistics {
return pa.statistics
}
// PathfindingLoader defines the interface for loading pathfinding data
type PathfindingLoader interface {
// LoadPathfindingData loads pathfinding data for a zone
LoadPathfindingData(zoneName string) (PathfindingBackend, error)
// GetSupportedBackends returns a list of supported pathfinding backends
GetSupportedBackends() []string
// CreateBackend creates a pathfinding backend of the specified type
CreateBackend(backendType string, zoneName string) (PathfindingBackend, error)
}

View File

@ -0,0 +1,268 @@
package pathfinder
import (
"fmt"
"log"
"math"
"sync/atomic"
"time"
)
// NewPathfinderManager creates a new pathfinder manager for a zone
func NewPathfinderManager(zoneName string) *PathfinderManager {
pm := &PathfinderManager{
zoneName: zoneName,
enabled: false,
}
// Always create a null pathfinder as fallback
pm.fallback = NewNullPathfinder()
pm.backend = pm.fallback
return pm
}
// SetBackend sets the pathfinding backend
func (pm *PathfinderManager) SetBackend(backend PathfindingBackend) error {
pm.mutex.Lock()
defer pm.mutex.Unlock()
if backend == nil {
return fmt.Errorf("pathfinding backend cannot be nil")
}
pm.backend = backend
pm.enabled = backend.IsLoaded()
log.Printf("[Pathfinder] Set backend '%s' for zone '%s' (enabled: %t)",
backend.GetName(), pm.zoneName, pm.enabled)
return nil
}
// SetEnabled enables or disables pathfinding
func (pm *PathfinderManager) SetEnabled(enabled bool) {
pm.mutex.Lock()
defer pm.mutex.Unlock()
pm.enabled = enabled && pm.backend.IsLoaded()
log.Printf("[Pathfinder] Pathfinding %s for zone '%s'",
map[bool]string{true: "enabled", false: "disabled"}[pm.enabled], pm.zoneName)
}
// IsEnabled returns whether pathfinding is enabled
func (pm *PathfinderManager) IsEnabled() bool {
pm.mutex.RLock()
defer pm.mutex.RUnlock()
return pm.enabled
}
// FindRoute finds a route between two points with basic options
func (pm *PathfinderManager) FindRoute(start, end [3]float32, flags PathingPolyFlags) *PathfindingResult {
startTime := time.Now()
defer func() {
atomic.AddInt64(&pm.pathRequests, 1)
duration := time.Since(startTime)
pm.updateAveragePathTime(float64(duration.Nanoseconds()) / 1e6) // Convert to milliseconds
}()
pm.mutex.RLock()
backend := pm.backend
enabled := pm.enabled
pm.mutex.RUnlock()
// Use fallback if pathfinding is disabled
if !enabled {
backend = pm.fallback
}
result := backend.FindRoute(start, end, flags)
pm.updateStats(result)
return result
}
// FindPath finds a path between two points with advanced options
func (pm *PathfinderManager) FindPath(start, end [3]float32, options *PathfinderOptions) *PathfindingResult {
startTime := time.Now()
defer func() {
atomic.AddInt64(&pm.pathRequests, 1)
duration := time.Since(startTime)
pm.updateAveragePathTime(float64(duration.Nanoseconds()) / 1e6) // Convert to milliseconds
}()
// Use default options if none provided
if options == nil {
options = GetDefaultPathfinderOptions()
}
pm.mutex.RLock()
backend := pm.backend
enabled := pm.enabled
pm.mutex.RUnlock()
// Use fallback if pathfinding is disabled
if !enabled {
backend = pm.fallback
}
result := backend.FindPath(start, end, options)
pm.updateStats(result)
return result
}
// GetRandomLocation returns a random walkable location near the start point
func (pm *PathfinderManager) GetRandomLocation(start [3]float32) [3]float32 {
pm.mutex.RLock()
backend := pm.backend
enabled := pm.enabled
pm.mutex.RUnlock()
// Use fallback if pathfinding is disabled
if !enabled {
backend = pm.fallback
}
return backend.GetRandomLocation(start)
}
// GetStats returns current pathfinding statistics
func (pm *PathfinderManager) GetStats() *PathfindingStats {
pm.mutex.RLock()
defer pm.mutex.RUnlock()
totalRequests := atomic.LoadInt64(&pm.pathRequests)
successfulPaths := atomic.LoadInt64(&pm.successfulPaths)
partialPaths := atomic.LoadInt64(&pm.partialPaths)
failedPaths := atomic.LoadInt64(&pm.failedPaths)
var successRate float64
if totalRequests > 0 {
successRate = float64(successfulPaths) / float64(totalRequests) * 100.0
}
return &PathfindingStats{
TotalRequests: totalRequests,
SuccessfulPaths: successfulPaths,
PartialPaths: partialPaths,
FailedPaths: failedPaths,
SuccessRate: successRate,
AveragePathTime: pm.averagePathTime,
BackendName: pm.backend.GetName(),
IsEnabled: pm.enabled,
}
}
// ResetStats resets all pathfinding statistics
func (pm *PathfinderManager) ResetStats() {
atomic.StoreInt64(&pm.pathRequests, 0)
atomic.StoreInt64(&pm.successfulPaths, 0)
atomic.StoreInt64(&pm.partialPaths, 0)
atomic.StoreInt64(&pm.failedPaths, 0)
pm.mutex.Lock()
pm.averagePathTime = 0.0
pm.mutex.Unlock()
}
// GetZoneName returns the zone name for this pathfinder
func (pm *PathfinderManager) GetZoneName() string {
return pm.zoneName
}
// GetBackendName returns the name of the current pathfinding backend
func (pm *PathfinderManager) GetBackendName() string {
pm.mutex.RLock()
defer pm.mutex.RUnlock()
return pm.backend.GetName()
}
// Private methods
func (pm *PathfinderManager) updateStats(result *PathfindingResult) {
if result == nil {
atomic.AddInt64(&pm.failedPaths, 1)
return
}
if result.Path != nil && !result.Partial {
atomic.AddInt64(&pm.successfulPaths, 1)
} else if result.Partial {
atomic.AddInt64(&pm.partialPaths, 1)
} else {
atomic.AddInt64(&pm.failedPaths, 1)
}
}
func (pm *PathfinderManager) updateAveragePathTime(timeMs float64) {
pm.mutex.Lock()
defer pm.mutex.Unlock()
// Simple moving average (this could be improved with more sophisticated averaging)
if pm.averagePathTime == 0.0 {
pm.averagePathTime = timeMs
} else {
pm.averagePathTime = (pm.averagePathTime*0.9 + timeMs*0.1)
}
}
// GetDefaultPathfinderOptions returns default pathfinding options
func GetDefaultPathfinderOptions() *PathfinderOptions {
return &PathfinderOptions{
Flags: PathingNotDisabled,
SmoothPath: true,
StepSize: DefaultStepSize,
FlagCost: [10]float32{
DefaultFlagCost0, DefaultFlagCost1, DefaultFlagCost2, DefaultFlagCost3, DefaultFlagCost4,
DefaultFlagCost5, DefaultFlagCost6, DefaultFlagCost7, DefaultFlagCost8, DefaultFlagCost9,
},
Offset: DefaultOffset,
}
}
// ValidatePath validates a generated path for basic correctness
func ValidatePath(path *Path) error {
if path == nil {
return fmt.Errorf("path is nil")
}
if len(path.Nodes) == 0 {
return fmt.Errorf("path has no nodes")
}
if len(path.Nodes) > MaxPathNodes {
return fmt.Errorf("path has too many nodes: %d > %d", len(path.Nodes), MaxPathNodes)
}
// Check for reasonable distances between nodes
totalDistance := float32(0.0)
for i := 1; i < len(path.Nodes); i++ {
prev := path.Nodes[i-1].Position
curr := path.Nodes[i].Position
dx := curr[0] - prev[0]
dy := curr[1] - prev[1]
dz := curr[2] - prev[2]
distance := float32(math.Sqrt(float64(dx*dx + dy*dy + dz*dz)))
if distance > MaxPathNodeDistance {
return fmt.Errorf("path node %d is too far from previous node: %.2f > %.2f",
i, distance, MaxPathNodeDistance)
}
totalDistance += distance
}
if totalDistance < MinPathDistance {
return fmt.Errorf("path is too short: %.2f < %.2f", totalDistance, MinPathDistance)
}
if totalDistance > MaxPathDistance {
return fmt.Errorf("path is too long: %.2f > %.2f", totalDistance, MaxPathDistance)
}
path.Distance = totalDistance
return nil
}

View File

@ -0,0 +1,74 @@
package pathfinder
import (
"math"
"math/rand"
)
// NullPathfinder is a fallback pathfinder that generates straight-line paths
// This is used when no proper pathfinding data is available
type NullPathfinder struct {
name string
}
// NewNullPathfinder creates a new null pathfinder
func NewNullPathfinder() *NullPathfinder {
return &NullPathfinder{
name: BackendTypeNull,
}
}
// FindRoute generates a straight-line path between start and end points
func (np *NullPathfinder) FindRoute(start, end [3]float32, flags PathingPolyFlags) *PathfindingResult {
return np.FindPath(start, end, GetDefaultPathfinderOptions())
}
// FindPath generates a straight-line path with the given options
func (np *NullPathfinder) FindPath(start, end [3]float32, options *PathfinderOptions) *PathfindingResult {
// Calculate distance
dx := end[0] - start[0]
dy := end[1] - start[1]
dz := end[2] - start[2]
distance := float32(math.Sqrt(float64(dx*dx + dy*dy + dz*dz)))
// Create simple two-node path (start -> end)
path := &Path{
Nodes: []*PathNode{
{Position: start, Teleport: false},
{Position: end, Teleport: false},
},
Partial: false, // Null pathfinder always generates complete paths
Distance: distance,
}
return &PathfindingResult{
Path: path,
Partial: false,
Stuck: false,
Distance: distance,
NodeCount: 2,
}
}
// GetRandomLocation returns a random location near the start point
func (np *NullPathfinder) GetRandomLocation(start [3]float32) [3]float32 {
// Generate random offset within RandomLocationRadius
angle := rand.Float32() * 2.0 * math.Pi
distance := rand.Float32() * RandomLocationRadius
return [3]float32{
start[0] + float32(math.Cos(float64(angle)))*distance,
start[1] + float32(math.Sin(float64(angle)))*distance,
start[2], // Keep same Z coordinate
}
}
// IsLoaded always returns true for null pathfinder
func (np *NullPathfinder) IsLoaded() bool {
return true
}
// GetName returns the name of this pathfinder
func (np *NullPathfinder) GetName() string {
return np.name
}

View File

@ -0,0 +1,102 @@
package pathfinder
import (
"sync"
)
// PathingPolyFlags defines different polygon flags for pathfinding
type PathingPolyFlags int32
const (
PathingNormal PathingPolyFlags = 1
PathingWater PathingPolyFlags = 2
PathingLava PathingPolyFlags = 4
PathingZoneLine PathingPolyFlags = 8
PathingPvP PathingPolyFlags = 16
PathingSlime PathingPolyFlags = 32
PathingIce PathingPolyFlags = 64
PathingVWater PathingPolyFlags = 128
PathingGeneralArea PathingPolyFlags = 256
PathingPortal PathingPolyFlags = 512
PathingPrefer PathingPolyFlags = 1024
PathingDisabled PathingPolyFlags = 2048
PathingAll PathingPolyFlags = 65535
PathingNotDisabled PathingPolyFlags = PathingAll ^ PathingDisabled
)
// PathfinderOptions configures pathfinding behavior
type PathfinderOptions struct {
Flags PathingPolyFlags // Allowed polygon flags
SmoothPath bool // Whether to smooth the final path
StepSize float32 // Step size for path smoothing
FlagCost [10]float32 // Cost multipliers for each flag type
Offset float32 // Offset from polygon edges
}
// PathNode represents a single node in a path
type PathNode struct {
Position [3]float32 // World position (x, y, z)
Teleport bool // Whether this is a teleport node
}
// Path represents a complete path from start to end
type Path struct {
Nodes []*PathNode // Ordered list of path nodes
Partial bool // Whether this is a partial path (couldn't reach destination)
Distance float32 // Total path distance
}
// PathfindingResult contains the results of a pathfinding operation
type PathfindingResult struct {
Path *Path // The generated path (nil if no path found)
Partial bool // Whether the path is partial
Stuck bool // Whether the pathfinder got stuck
Distance float32 // Total path distance
NodeCount int // Number of nodes in the path
}
// PathfindingBackend defines the interface for pathfinding implementations
type PathfindingBackend interface {
// FindRoute finds a route between two points with basic options
FindRoute(start, end [3]float32, flags PathingPolyFlags) *PathfindingResult
// FindPath finds a path between two points with advanced options
FindPath(start, end [3]float32, options *PathfinderOptions) *PathfindingResult
// GetRandomLocation returns a random walkable location near the start point
GetRandomLocation(start [3]float32) [3]float32
// IsLoaded returns whether the pathfinding data is loaded
IsLoaded() bool
// GetName returns the name of this pathfinding backend
GetName() string
}
// PathfinderManager manages pathfinding for a zone
type PathfinderManager struct {
zoneName string // Zone name for this pathfinder
backend PathfindingBackend // Active pathfinding backend
fallback PathfindingBackend // Fallback backend (usually null pathfinder)
enabled bool // Whether pathfinding is enabled
mutex sync.RWMutex // Thread safety
// Statistics
pathRequests int64 // Total path requests
successfulPaths int64 // Successful path generations
partialPaths int64 // Partial path generations
failedPaths int64 // Failed path generations
averagePathTime float64 // Average pathfinding time in milliseconds
}
// PathfindingStats contains pathfinding statistics
type PathfindingStats struct {
TotalRequests int64 `json:"total_requests"`
SuccessfulPaths int64 `json:"successful_paths"`
PartialPaths int64 `json:"partial_paths"`
FailedPaths int64 `json:"failed_paths"`
SuccessRate float64 `json:"success_rate"`
AveragePathTime float64 `json:"average_path_time_ms"`
BackendName string `json:"backend_name"`
IsEnabled bool `json:"is_enabled"`
}

489
internal/zone/position.go Normal file
View File

@ -0,0 +1,489 @@
package zone
import (
"math"
)
// Position represents a 3D position with heading
type Position struct {
X float32
Y float32
Z float32
Heading float32
}
// Position2D represents a 2D position
type Position2D struct {
X float32
Y float32
}
// BoundingBox represents an axis-aligned bounding box
type BoundingBox struct {
MinX float32
MinY float32
MinZ float32
MaxX float32
MaxY float32
MaxZ float32
}
// Cylinder represents a cylindrical collision shape
type Cylinder struct {
X float32
Y float32
Z float32
Radius float32
Height float32
}
const (
// EQ2HeadingMax represents the maximum heading value in EQ2 (512 = full circle)
EQ2HeadingMax = 512.0
// DefaultEpsilon for floating point comparisons
DefaultEpsilon = 0.0001
// DegreesToRadians conversion factor
DegreesToRadians = math.Pi / 180.0
// RadiansToDegrees conversion factor
RadiansToDegrees = 180.0 / math.Pi
)
// NewPosition creates a new position with the given coordinates and heading
func NewPosition(x, y, z, heading float32) *Position {
return &Position{
X: x,
Y: y,
Z: z,
Heading: heading,
}
}
// NewPosition2D creates a new 2D position
func NewPosition2D(x, y float32) *Position2D {
return &Position2D{
X: x,
Y: y,
}
}
// NewBoundingBox creates a new bounding box with the given bounds
func NewBoundingBox(minX, minY, minZ, maxX, maxY, maxZ float32) *BoundingBox {
return &BoundingBox{
MinX: minX,
MinY: minY,
MinZ: minZ,
MaxX: maxX,
MaxY: maxY,
MaxZ: maxZ,
}
}
// NewCylinder creates a new cylinder with the given parameters
func NewCylinder(x, y, z, radius, height float32) *Cylinder {
return &Cylinder{
X: x,
Y: y,
Z: z,
Radius: radius,
Height: height,
}
}
// Copy creates a copy of the position
func (p *Position) Copy() *Position {
return &Position{
X: p.X,
Y: p.Y,
Z: p.Z,
Heading: p.Heading,
}
}
// Set updates the position with new values
func (p *Position) Set(x, y, z, heading float32) {
p.X = x
p.Y = y
p.Z = z
p.Heading = heading
}
// SetXYZ updates only the coordinates, leaving heading unchanged
func (p *Position) SetXYZ(x, y, z float32) {
p.X = x
p.Y = y
p.Z = z
}
// SetHeading updates only the heading
func (p *Position) SetHeading(heading float32) {
p.Heading = heading
}
// Distance2D calculates the 2D distance to another position (ignoring Z)
func Distance2D(x1, y1, x2, y2 float32) float32 {
dx := x2 - x1
dy := y2 - y1
return float32(math.Sqrt(float64(dx*dx + dy*dy)))
}
// Distance2DSquared calculates the squared 2D distance (more efficient, avoids sqrt)
func Distance2DSquared(x1, y1, x2, y2 float32) float32 {
dx := x2 - x1
dy := y2 - y1
return dx*dx + dy*dy
}
// Distance3D calculates the 3D distance between two points
func Distance3D(x1, y1, z1, x2, y2, z2 float32) float32 {
dx := x2 - x1
dy := y2 - y1
dz := z2 - z1
return float32(math.Sqrt(float64(dx*dx + dy*dy + dz*dz)))
}
// Distance3DSquared calculates the squared 3D distance (more efficient, avoids sqrt)
func Distance3DSquared(x1, y1, z1, x2, y2, z2 float32) float32 {
dx := x2 - x1
dy := y2 - y1
dz := z2 - z1
return dx*dx + dy*dy + dz*dz
}
// Distance4D calculates the 4D distance including heading difference
func Distance4D(x1, y1, z1, h1, x2, y2, z2, h2 float32) float32 {
dx := x2 - x1
dy := y2 - y1
dz := z2 - z1
dh := HeadingDifference(h1, h2)
return float32(math.Sqrt(float64(dx*dx + dy*dy + dz*dz + dh*dh)))
}
// DistanceTo2D calculates the 2D distance to another position
func (p *Position) DistanceTo2D(other *Position) float32 {
return Distance2D(p.X, p.Y, other.X, other.Y)
}
// DistanceTo3D calculates the 3D distance to another position
func (p *Position) DistanceTo3D(other *Position) float32 {
return Distance3D(p.X, p.Y, p.Z, other.X, other.Y, other.Z)
}
// DistanceTo4D calculates the 4D distance to another position including heading
func (p *Position) DistanceTo4D(other *Position) float32 {
return Distance4D(p.X, p.Y, p.Z, p.Heading, other.X, other.Y, other.Z, other.Heading)
}
// CalculateHeading calculates the heading from current position to target position
func (p *Position) CalculateHeading(targetX, targetY float32) float32 {
return CalculateHeading(p.X, p.Y, targetX, targetY)
}
// CalculateHeading calculates the EQ2 heading from one point to another
func CalculateHeading(fromX, fromY, toX, toY float32) float32 {
dx := toX - fromX
dy := toY - fromY
if dx == 0 && dy == 0 {
return 0.0
}
// Calculate angle in radians
angle := math.Atan2(float64(dx), float64(dy))
// Convert to EQ2 heading (0-512 scale, 0 = north)
heading := angle * (EQ2HeadingMax / (2 * math.Pi))
// Ensure positive value
if heading < 0 {
heading += EQ2HeadingMax
}
return float32(heading)
}
// HeadingToRadians converts an EQ2 heading to radians
func HeadingToRadians(heading float32) float32 {
return heading * (2 * math.Pi / EQ2HeadingMax)
}
// RadiansToHeading converts radians to an EQ2 heading
func RadiansToHeading(radians float32) float32 {
heading := radians * (EQ2HeadingMax / (2 * math.Pi))
if heading < 0 {
heading += EQ2HeadingMax
}
return heading
}
// HeadingToRadiansDegrees converts an EQ2 heading to degrees
func HeadingToDegrees(heading float32) float32 {
return heading * (360.0 / EQ2HeadingMax)
}
// DegreesToHeading converts degrees to an EQ2 heading
func DegreesToHeading(degrees float32) float32 {
heading := degrees * (EQ2HeadingMax / 360.0)
if heading < 0 {
heading += EQ2HeadingMax
}
return heading
}
// NormalizeHeading ensures a heading is in the valid range [0, 512)
func NormalizeHeading(heading float32) float32 {
for heading < 0 {
heading += EQ2HeadingMax
}
for heading >= EQ2HeadingMax {
heading -= EQ2HeadingMax
}
return heading
}
// HeadingDifference calculates the shortest angular difference between two headings
func HeadingDifference(heading1, heading2 float32) float32 {
diff := heading2 - heading1
// Normalize to [-256, 256] range (half circle)
for diff > EQ2HeadingMax/2 {
diff -= EQ2HeadingMax
}
for diff < -EQ2HeadingMax/2 {
diff += EQ2HeadingMax
}
return diff
}
// GetReciprocalHeading calculates the opposite heading (180 degree turn)
func GetReciprocalHeading(heading float32) float32 {
reciprocal := heading + EQ2HeadingMax/2
if reciprocal >= EQ2HeadingMax {
reciprocal -= EQ2HeadingMax
}
return reciprocal
}
// Equals compares two positions with epsilon tolerance
func (p *Position) Equals(other *Position) bool {
return EqualsWithEpsilon(p.X, other.X, DefaultEpsilon) &&
EqualsWithEpsilon(p.Y, other.Y, DefaultEpsilon) &&
EqualsWithEpsilon(p.Z, other.Z, DefaultEpsilon) &&
EqualsWithEpsilon(p.Heading, other.Heading, DefaultEpsilon)
}
// EqualsXYZ compares only the coordinates (ignoring heading)
func (p *Position) EqualsXYZ(other *Position) bool {
return EqualsWithEpsilon(p.X, other.X, DefaultEpsilon) &&
EqualsWithEpsilon(p.Y, other.Y, DefaultEpsilon) &&
EqualsWithEpsilon(p.Z, other.Z, DefaultEpsilon)
}
// EqualsWithEpsilon compares two float values with the given epsilon tolerance
func EqualsWithEpsilon(a, b, epsilon float32) bool {
diff := a - b
if diff < 0 {
diff = -diff
}
return diff < epsilon
}
// Contains checks if a point is inside the bounding box
func (bb *BoundingBox) Contains(x, y, z float32) bool {
return x >= bb.MinX && x <= bb.MaxX &&
y >= bb.MinY && y <= bb.MaxY &&
z >= bb.MinZ && z <= bb.MaxZ
}
// ContainsPosition checks if a position is inside the bounding box
func (bb *BoundingBox) ContainsPosition(pos *Position) bool {
return bb.Contains(pos.X, pos.Y, pos.Z)
}
// Intersects checks if this bounding box intersects with another
func (bb *BoundingBox) Intersects(other *BoundingBox) bool {
return bb.MinX <= other.MaxX && bb.MaxX >= other.MinX &&
bb.MinY <= other.MaxY && bb.MaxY >= other.MinY &&
bb.MinZ <= other.MaxZ && bb.MaxZ >= other.MinZ
}
// Expand expands the bounding box by the given amount in all directions
func (bb *BoundingBox) Expand(amount float32) {
bb.MinX -= amount
bb.MinY -= amount
bb.MinZ -= amount
bb.MaxX += amount
bb.MaxY += amount
bb.MaxZ += amount
}
// GetCenter returns the center point of the bounding box
func (bb *BoundingBox) GetCenter() *Position {
return &Position{
X: (bb.MinX + bb.MaxX) / 2,
Y: (bb.MinY + bb.MaxY) / 2,
Z: (bb.MinZ + bb.MaxZ) / 2,
}
}
// GetSize returns the size of the bounding box in each dimension
func (bb *BoundingBox) GetSize() (width, height, depth float32) {
return bb.MaxX - bb.MinX, bb.MaxY - bb.MinY, bb.MaxZ - bb.MinZ
}
// Contains2D checks if a 2D point is inside the cylinder (ignoring height)
func (c *Cylinder) Contains2D(x, y float32) bool {
return Distance2DSquared(c.X, c.Y, x, y) <= c.Radius*c.Radius
}
// Contains3D checks if a 3D point is inside the cylinder
func (c *Cylinder) Contains3D(x, y, z float32) bool {
if z < c.Z || z > c.Z+c.Height {
return false
}
return c.Contains2D(x, y)
}
// ContainsPosition checks if a position is inside the cylinder
func (c *Cylinder) ContainsPosition(pos *Position) bool {
return c.Contains3D(pos.X, pos.Y, pos.Z)
}
// DistanceToEdge2D calculates the 2D distance from a point to the cylinder edge
func (c *Cylinder) DistanceToEdge2D(x, y float32) float32 {
distance := Distance2D(c.X, c.Y, x, y)
return distance - c.Radius
}
// GetBoundingBox returns a bounding box that encompasses the cylinder
func (c *Cylinder) GetBoundingBox() *BoundingBox {
return &BoundingBox{
MinX: c.X - c.Radius,
MinY: c.Y - c.Radius,
MinZ: c.Z,
MaxX: c.X + c.Radius,
MaxY: c.Y + c.Radius,
MaxZ: c.Z + c.Height,
}
}
// Copy creates a copy of the 2D position
func (p *Position2D) Copy() *Position2D {
return &Position2D{
X: p.X,
Y: p.Y,
}
}
// DistanceTo calculates the distance to another 2D position
func (p *Position2D) DistanceTo(other *Position2D) float32 {
return Distance2D(p.X, p.Y, other.X, other.Y)
}
// DistanceToSquared calculates the squared distance to another 2D position
func (p *Position2D) DistanceToSquared(other *Position2D) float32 {
return Distance2DSquared(p.X, p.Y, other.X, other.Y)
}
// Equals compares two 2D positions with epsilon tolerance
func (p *Position2D) Equals(other *Position2D) bool {
return EqualsWithEpsilon(p.X, other.X, DefaultEpsilon) &&
EqualsWithEpsilon(p.Y, other.Y, DefaultEpsilon)
}
// InterpolateLinear performs linear interpolation between two positions
func InterpolateLinear(from, to *Position, t float32) *Position {
if t <= 0 {
return from.Copy()
}
if t >= 1 {
return to.Copy()
}
return &Position{
X: from.X + (to.X-from.X)*t,
Y: from.Y + (to.Y-from.Y)*t,
Z: from.Z + (to.Z-from.Z)*t,
Heading: InterpolateHeading(from.Heading, to.Heading, t),
}
}
// InterpolateHeading performs interpolation between two headings, taking the shortest path
func InterpolateHeading(from, to, t float32) float32 {
diff := HeadingDifference(from, to)
result := from + diff*t
return NormalizeHeading(result)
}
// GetRandomPositionInRadius generates a random position within the given radius
func GetRandomPositionInRadius(centerX, centerY, centerZ float32, radius float32) *Position {
// Generate random angle
angle := float32(math.Random() * 2 * math.Pi)
// Generate random distance (uniform distribution in circle)
distance := float32(math.Sqrt(math.Random())) * radius
// Calculate new position
x := centerX + distance*float32(math.Cos(float64(angle)))
y := centerY + distance*float32(math.Sin(float64(angle)))
return &Position{
X: x,
Y: y,
Z: centerZ,
Heading: 0,
}
}
// IsWithinRange checks if two positions are within the specified range
func IsWithinRange(pos1, pos2 *Position, maxRange float32) bool {
return Distance3DSquared(pos1.X, pos1.Y, pos1.Z, pos2.X, pos2.Y, pos2.Z) <= maxRange*maxRange
}
// IsWithinRange2D checks if two positions are within the specified 2D range
func IsWithinRange2D(pos1, pos2 *Position, maxRange float32) bool {
return Distance2DSquared(pos1.X, pos1.Y, pos2.X, pos2.Y) <= maxRange*maxRange
}
// ClampToRange clamps a distance to be within the specified range
func ClampToRange(distance, minRange, maxRange float32) float32 {
if distance < minRange {
return minRange
}
if distance > maxRange {
return maxRange
}
return distance
}
// GetDirectionVector calculates a normalized direction vector from one position to another
func GetDirectionVector(from, to *Position) (dx, dy, dz float32) {
dx = to.X - from.X
dy = to.Y - from.Y
dz = to.Z - from.Z
// Normalize
length := float32(math.Sqrt(float64(dx*dx + dy*dy + dz*dz)))
if length > 0 {
dx /= length
dy /= length
dz /= length
}
return dx, dy, dz
}
// MoveTowards moves a position towards a target by the specified distance
func MoveTowards(from, to *Position, distance float32) *Position {
dx, dy, dz := GetDirectionVector(from, to)
return &Position{
X: from.X + dx*distance,
Y: from.Y + dy*distance,
Z: from.Z + dz*distance,
Heading: from.Heading,
}
}

View File

@ -0,0 +1,38 @@
package raycast
// Default configuration values for raycast mesh creation
const (
// DefaultMaxDepth is the default maximum recursion depth for the AABB tree
DefaultMaxDepth = 15
// DefaultMinLeafSize is the default minimum triangles to treat as a 'leaf' node
DefaultMinLeafSize = 4
// DefaultMinAxisSize is the default minimum axis size for subdivision
DefaultMinAxisSize = 0.01
// EpsilonFloat is the floating point epsilon for comparisons
EpsilonFloat = 1e-6
// EpsilonDouble is the double precision epsilon for comparisons
EpsilonDouble = 1e-12
)
// Triangle indices for accessing vertex components
const (
// Vertex component indices
X = 0
Y = 1
Z = 2
// Triangle vertex indices (3 vertices * 3 components each)
V0X = 0 // First vertex X
V0Y = 1 // First vertex Y
V0Z = 2 // First vertex Z
V1X = 3 // Second vertex X
V1Y = 4 // Second vertex Y
V1Z = 5 // Second vertex Z
V2X = 6 // Third vertex X
V2Y = 7 // Third vertex Y
V2Z = 8 // Third vertex Z
)

View File

@ -0,0 +1,590 @@
package raycast
import (
"fmt"
"math"
"sync"
)
// RaycastMesh provides high-speed raycasting against triangle meshes using AABB trees
// This is a Go implementation based on the C++ raycast mesh system
type RaycastMesh struct {
vertices []float32 // Vertex positions (x,y,z,x,y,z,...)
indices []uint32 // Triangle indices (i1,i2,i3,i4,i5,i6,...)
grids []uint32 // Grid IDs for each triangle
widgets []uint32 // Widget IDs for each triangle
triangles []*Triangle // Processed triangles
root *AABBNode // Root of the AABB tree
boundMin [3]float32 // Minimum bounding box
boundMax [3]float32 // Maximum bounding box
maxDepth uint32 // Maximum tree depth
minLeafSize uint32 // Minimum triangles per leaf
minAxisSize float32 // Minimum axis size for subdivision
mutex sync.RWMutex
}
// Triangle represents a single triangle in the mesh
type Triangle struct {
Vertices [9]float32 // 3 vertices * 3 components (x,y,z)
Normal [3]float32 // Face normal
GridID uint32 // Associated grid ID
WidgetID uint32 // Associated widget ID
BoundMin [3]float32 // Triangle bounding box minimum
BoundMax [3]float32 // Triangle bounding box maximum
}
// AABBNode represents a node in the Axis-Aligned Bounding Box tree
type AABBNode struct {
BoundMin [3]float32 // Node bounding box minimum
BoundMax [3]float32 // Node bounding box maximum
Left *AABBNode // Left child (nil for leaf nodes)
Right *AABBNode // Right child (nil for leaf nodes)
Triangles []*Triangle // Triangles (only for leaf nodes)
Depth uint32 // Tree depth
}
// RaycastResult contains the results of a raycast operation
type RaycastResult struct {
Hit bool // Whether the ray hit something
HitLocation [3]float32 // World coordinates of hit point
HitNormal [3]float32 // Surface normal at hit point
HitDistance float32 // Distance from ray origin to hit
GridID uint32 // Grid ID of hit triangle
WidgetID uint32 // Widget ID of hit triangle
}
// RaycastOptions configures raycast behavior
type RaycastOptions struct {
IgnoredWidgets map[uint32]bool // Widget IDs to ignore during raycast
MaxDistance float32 // Maximum ray distance (0 = unlimited)
BothSides bool // Check both sides of triangles
}
// NewRaycastMesh creates a new raycast mesh from triangle data
func NewRaycastMesh(vertices []float32, indices []uint32, grids []uint32, widgets []uint32,
maxDepth uint32, minLeafSize uint32, minAxisSize float32) (*RaycastMesh, error) {
if len(vertices)%3 != 0 {
return nil, fmt.Errorf("vertex count must be divisible by 3")
}
if len(indices)%3 != 0 {
return nil, fmt.Errorf("index count must be divisible by 3")
}
triangleCount := len(indices) / 3
if len(grids) != triangleCount {
return nil, fmt.Errorf("grid count must match triangle count")
}
if len(widgets) != triangleCount {
return nil, fmt.Errorf("widget count must match triangle count")
}
rm := &RaycastMesh{
vertices: make([]float32, len(vertices)),
indices: make([]uint32, len(indices)),
grids: make([]uint32, len(grids)),
widgets: make([]uint32, len(widgets)),
triangles: make([]*Triangle, triangleCount),
maxDepth: maxDepth,
minLeafSize: minLeafSize,
minAxisSize: minAxisSize,
}
// Copy input data
copy(rm.vertices, vertices)
copy(rm.indices, indices)
copy(rm.grids, grids)
copy(rm.widgets, widgets)
// Process triangles
if err := rm.processTriangles(); err != nil {
return nil, fmt.Errorf("failed to process triangles: %v", err)
}
// Build AABB tree
if err := rm.buildAABBTree(); err != nil {
return nil, fmt.Errorf("failed to build AABB tree: %v", err)
}
return rm, nil
}
// Raycast performs optimized raycasting using the AABB tree
func (rm *RaycastMesh) Raycast(from, to [3]float32, options *RaycastOptions) *RaycastResult {
rm.mutex.RLock()
defer rm.mutex.RUnlock()
if options == nil {
options = &RaycastOptions{}
}
result := &RaycastResult{
Hit: false,
HitDistance: math.MaxFloat32,
}
// Calculate ray direction and length
rayDir := [3]float32{
to[0] - from[0],
to[1] - from[1],
to[2] - from[2],
}
rayLength := vectorLength(rayDir)
if rayLength < 1e-6 {
return result // Zero-length ray
}
// Normalize ray direction
rayDir[0] /= rayLength
rayDir[1] /= rayLength
rayDir[2] /= rayLength
// Use max distance if specified
maxDist := rayLength
if options.MaxDistance > 0 && options.MaxDistance < rayLength {
maxDist = options.MaxDistance
}
// Traverse AABB tree
rm.raycastNode(rm.root, from, rayDir, maxDist, options, result)
return result
}
// BruteForceRaycast performs raycast without spatial optimization (for testing/comparison)
func (rm *RaycastMesh) BruteForceRaycast(from, to [3]float32, options *RaycastOptions) *RaycastResult {
rm.mutex.RLock()
defer rm.mutex.RUnlock()
if options == nil {
options = &RaycastOptions{}
}
result := &RaycastResult{
Hit: false,
HitDistance: math.MaxFloat32,
}
rayDir := [3]float32{
to[0] - from[0],
to[1] - from[1],
to[2] - from[2],
}
rayLength := vectorLength(rayDir)
if rayLength < 1e-6 {
return result
}
rayDir[0] /= rayLength
rayDir[1] /= rayLength
rayDir[2] /= rayLength
maxDist := rayLength
if options.MaxDistance > 0 && options.MaxDistance < rayLength {
maxDist = options.MaxDistance
}
// Test all triangles
for _, triangle := range rm.triangles {
if options.IgnoredWidgets != nil && options.IgnoredWidgets[triangle.WidgetID] {
continue
}
if hitDist, hitPoint, hitNormal := rm.rayTriangleIntersect(from, rayDir, triangle, options.BothSides); hitDist >= 0 && hitDist <= maxDist && hitDist < result.HitDistance {
result.Hit = true
result.HitDistance = hitDist
result.HitLocation = hitPoint
result.HitNormal = hitNormal
result.GridID = triangle.GridID
result.WidgetID = triangle.WidgetID
}
}
return result
}
// GetBoundMin returns the minimum bounding box coordinates
func (rm *RaycastMesh) GetBoundMin() [3]float32 {
rm.mutex.RLock()
defer rm.mutex.RUnlock()
return rm.boundMin
}
// GetBoundMax returns the maximum bounding box coordinates
func (rm *RaycastMesh) GetBoundMax() [3]float32 {
rm.mutex.RLock()
defer rm.mutex.RUnlock()
return rm.boundMax
}
// GetTriangleCount returns the number of triangles in the mesh
func (rm *RaycastMesh) GetTriangleCount() int {
rm.mutex.RLock()
defer rm.mutex.RUnlock()
return len(rm.triangles)
}
// GetTreeDepth returns the actual depth of the AABB tree
func (rm *RaycastMesh) GetTreeDepth() uint32 {
rm.mutex.RLock()
defer rm.mutex.RUnlock()
if rm.root == nil {
return 0
}
return rm.getNodeDepth(rm.root)
}
// Private methods
func (rm *RaycastMesh) processTriangles() error {
rm.boundMin = [3]float32{math.MaxFloat32, math.MaxFloat32, math.MaxFloat32}
rm.boundMax = [3]float32{-math.MaxFloat32, -math.MaxFloat32, -math.MaxFloat32}
for i := 0; i < len(rm.indices); i += 3 {
triangle := &Triangle{
GridID: rm.grids[i/3],
WidgetID: rm.widgets[i/3],
}
// Get vertex indices
i1, i2, i3 := rm.indices[i], rm.indices[i+1], rm.indices[i+2]
// Validate indices
if i1*3+2 >= uint32(len(rm.vertices)) || i2*3+2 >= uint32(len(rm.vertices)) || i3*3+2 >= uint32(len(rm.vertices)) {
return fmt.Errorf("invalid vertex index in triangle %d", i/3)
}
// Copy vertex positions
copy(triangle.Vertices[0:3], rm.vertices[i1*3:i1*3+3])
copy(triangle.Vertices[3:6], rm.vertices[i2*3:i2*3+3])
copy(triangle.Vertices[6:9], rm.vertices[i3*3:i3*3+3])
// Calculate triangle bounding box
triangle.BoundMin = [3]float32{math.MaxFloat32, math.MaxFloat32, math.MaxFloat32}
triangle.BoundMax = [3]float32{-math.MaxFloat32, -math.MaxFloat32, -math.MaxFloat32}
for j := 0; j < 9; j += 3 {
for k := 0; k < 3; k++ {
if triangle.Vertices[j+k] < triangle.BoundMin[k] {
triangle.BoundMin[k] = triangle.Vertices[j+k]
}
if triangle.Vertices[j+k] > triangle.BoundMax[k] {
triangle.BoundMax[k] = triangle.Vertices[j+k]
}
}
}
// Update global bounding box
for k := 0; k < 3; k++ {
if triangle.BoundMin[k] < rm.boundMin[k] {
rm.boundMin[k] = triangle.BoundMin[k]
}
if triangle.BoundMax[k] > rm.boundMax[k] {
rm.boundMax[k] = triangle.BoundMax[k]
}
}
// Calculate face normal
rm.calculateTriangleNormal(triangle)
rm.triangles[i/3] = triangle
}
return nil
}
func (rm *RaycastMesh) calculateTriangleNormal(triangle *Triangle) {
// Calculate two edge vectors
edge1 := [3]float32{
triangle.Vertices[3] - triangle.Vertices[0],
triangle.Vertices[4] - triangle.Vertices[1],
triangle.Vertices[5] - triangle.Vertices[2],
}
edge2 := [3]float32{
triangle.Vertices[6] - triangle.Vertices[0],
triangle.Vertices[7] - triangle.Vertices[1],
triangle.Vertices[8] - triangle.Vertices[2],
}
// Cross product
triangle.Normal[0] = edge1[1]*edge2[2] - edge1[2]*edge2[1]
triangle.Normal[1] = edge1[2]*edge2[0] - edge1[0]*edge2[2]
triangle.Normal[2] = edge1[0]*edge2[1] - edge1[1]*edge2[0]
// Normalize
length := vectorLength(triangle.Normal)
if length > 1e-6 {
triangle.Normal[0] /= length
triangle.Normal[1] /= length
triangle.Normal[2] /= length
}
}
func (rm *RaycastMesh) buildAABBTree() error {
if len(rm.triangles) == 0 {
return fmt.Errorf("no triangles to build tree from")
}
// Create root node with all triangles
rm.root = &AABBNode{
BoundMin: rm.boundMin,
BoundMax: rm.boundMax,
Triangles: rm.triangles,
Depth: 0,
}
// Recursively subdivide
rm.subdivideNode(rm.root)
return nil
}
func (rm *RaycastMesh) subdivideNode(node *AABBNode) {
// Stop subdivision if we've reached limits
if node.Depth >= rm.maxDepth || uint32(len(node.Triangles)) <= rm.minLeafSize {
return
}
// Find longest axis
size := [3]float32{
node.BoundMax[0] - node.BoundMin[0],
node.BoundMax[1] - node.BoundMin[1],
node.BoundMax[2] - node.BoundMin[2],
}
axis := 0
if size[1] > size[axis] {
axis = 1
}
if size[2] > size[axis] {
axis = 2
}
// Stop if axis is too small
if size[axis] < rm.minAxisSize {
return
}
// Split at midpoint
split := node.BoundMin[axis] + size[axis]*0.5
// Partition triangles
var leftTriangles, rightTriangles []*Triangle
for _, triangle := range node.Triangles {
center := (triangle.BoundMin[axis] + triangle.BoundMax[axis]) * 0.5
if center < split {
leftTriangles = append(leftTriangles, triangle)
} else {
rightTriangles = append(rightTriangles, triangle)
}
}
// Make sure both sides have triangles
if len(leftTriangles) == 0 || len(rightTriangles) == 0 {
return
}
// Create child nodes
node.Left = &AABBNode{
Triangles: leftTriangles,
Depth: node.Depth + 1,
}
node.Right = &AABBNode{
Triangles: rightTriangles,
Depth: node.Depth + 1,
}
// Calculate child bounding boxes
rm.calculateNodeBounds(node.Left)
rm.calculateNodeBounds(node.Right)
// Clear triangles from internal node
node.Triangles = nil
// Recursively subdivide children
rm.subdivideNode(node.Left)
rm.subdivideNode(node.Right)
}
func (rm *RaycastMesh) calculateNodeBounds(node *AABBNode) {
if len(node.Triangles) == 0 {
return
}
node.BoundMin = [3]float32{math.MaxFloat32, math.MaxFloat32, math.MaxFloat32}
node.BoundMax = [3]float32{-math.MaxFloat32, -math.MaxFloat32, -math.MaxFloat32}
for _, triangle := range node.Triangles {
for k := 0; k < 3; k++ {
if triangle.BoundMin[k] < node.BoundMin[k] {
node.BoundMin[k] = triangle.BoundMin[k]
}
if triangle.BoundMax[k] > node.BoundMax[k] {
node.BoundMax[k] = triangle.BoundMax[k]
}
}
}
}
func (rm *RaycastMesh) raycastNode(node *AABBNode, rayOrigin, rayDir [3]float32, maxDist float32,
options *RaycastOptions, result *RaycastResult) {
if node == nil {
return
}
// Test ray against node bounding box
if !rm.rayAABBIntersect(rayOrigin, rayDir, node.BoundMin, node.BoundMax, maxDist) {
return
}
// Leaf node - test triangles
if node.Left == nil && node.Right == nil {
for _, triangle := range node.Triangles {
if options.IgnoredWidgets != nil && options.IgnoredWidgets[triangle.WidgetID] {
continue
}
if hitDist, hitPoint, hitNormal := rm.rayTriangleIntersect(rayOrigin, rayDir, triangle, options.BothSides); hitDist >= 0 && hitDist <= maxDist && hitDist < result.HitDistance {
result.Hit = true
result.HitDistance = hitDist
result.HitLocation = hitPoint
result.HitNormal = hitNormal
result.GridID = triangle.GridID
result.WidgetID = triangle.WidgetID
}
}
} else {
// Internal node - recurse to children
rm.raycastNode(node.Left, rayOrigin, rayDir, maxDist, options, result)
rm.raycastNode(node.Right, rayOrigin, rayDir, maxDist, options, result)
}
}
func (rm *RaycastMesh) rayAABBIntersect(rayOrigin, rayDir [3]float32, boundMin, boundMax [3]float32, maxDist float32) bool {
var tMin, tMax float32 = 0, maxDist
for i := 0; i < 3; i++ {
if math.Abs(float64(rayDir[i])) < 1e-6 {
// Ray is parallel to axis
if rayOrigin[i] < boundMin[i] || rayOrigin[i] > boundMax[i] {
return false
}
} else {
// Calculate intersection distances
invDir := 1.0 / rayDir[i]
t1 := (boundMin[i] - rayOrigin[i]) * invDir
t2 := (boundMax[i] - rayOrigin[i]) * invDir
if t1 > t2 {
t1, t2 = t2, t1
}
tMin = float32(math.Max(float64(tMin), float64(t1)))
tMax = float32(math.Min(float64(tMax), float64(t2)))
if tMin > tMax {
return false
}
}
}
return tMin <= maxDist
}
func (rm *RaycastMesh) rayTriangleIntersect(rayOrigin, rayDir [3]float32, triangle *Triangle, bothSides bool) (float32, [3]float32, [3]float32) {
// Möller-Trumbore ray-triangle intersection algorithm
// Get triangle vertices
v0 := [3]float32{triangle.Vertices[0], triangle.Vertices[1], triangle.Vertices[2]}
v1 := [3]float32{triangle.Vertices[3], triangle.Vertices[4], triangle.Vertices[5]}
v2 := [3]float32{triangle.Vertices[6], triangle.Vertices[7], triangle.Vertices[8]}
// Edge vectors
edge1 := [3]float32{v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]}
edge2 := [3]float32{v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]}
// Cross product of ray direction and edge2
h := [3]float32{
rayDir[1]*edge2[2] - rayDir[2]*edge2[1],
rayDir[2]*edge2[0] - rayDir[0]*edge2[2],
rayDir[0]*edge2[1] - rayDir[1]*edge2[0],
}
// Dot product of edge1 and h
a := edge1[0]*h[0] + edge1[1]*h[1] + edge1[2]*h[2]
if math.Abs(float64(a)) < 1e-6 {
return -1, [3]float32{}, [3]float32{} // Ray is parallel to triangle
}
f := 1.0 / a
s := [3]float32{rayOrigin[0] - v0[0], rayOrigin[1] - v0[1], rayOrigin[2] - v0[2]}
u := f * (s[0]*h[0] + s[1]*h[1] + s[2]*h[2])
if u < 0.0 || u > 1.0 {
return -1, [3]float32{}, [3]float32{}
}
q := [3]float32{
s[1]*edge1[2] - s[2]*edge1[1],
s[2]*edge1[0] - s[0]*edge1[2],
s[0]*edge1[1] - s[1]*edge1[0],
}
v := f * (rayDir[0]*q[0] + rayDir[1]*q[1] + rayDir[2]*q[2])
if v < 0.0 || u+v > 1.0 {
return -1, [3]float32{}, [3]float32{}
}
t := f * (edge2[0]*q[0] + edge2[1]*q[1] + edge2[2]*q[2])
if t < 1e-6 { // Ray intersection behind origin
return -1, [3]float32{}, [3]float32{}
}
// Check backface culling
if !bothSides && a > 0 {
return -1, [3]float32{}, [3]float32{}
}
// Calculate hit point
hitPoint := [3]float32{
rayOrigin[0] + rayDir[0]*t,
rayOrigin[1] + rayDir[1]*t,
rayOrigin[2] + rayDir[2]*t,
}
// Use precomputed normal
hitNormal := triangle.Normal
return t, hitPoint, hitNormal
}
func (rm *RaycastMesh) getNodeDepth(node *AABBNode) uint32 {
if node == nil {
return 0
}
if node.Left == nil && node.Right == nil {
return node.Depth
}
leftDepth := rm.getNodeDepth(node.Left)
rightDepth := rm.getNodeDepth(node.Right)
if leftDepth > rightDepth {
return leftDepth
}
return rightDepth
}
// Utility functions
func vectorLength(v [3]float32) float32 {
return float32(math.Sqrt(float64(v[0]*v[0] + v[1]*v[1] + v[2]*v[2])))
}

View File

@ -0,0 +1,74 @@
package region
// Default region configuration values
const (
// Region check intervals
RegionCheckInterval = 1000 // Milliseconds between region checks
RegionUpdateInterval = 5000 // Milliseconds between region updates
RegionCleanupInterval = 30000 // Milliseconds between cleanup operations
// Region detection parameters
RegionCheckRadius = 100.0 // Radius for region detection
RegionTriggerDistance = 50.0 // Default trigger distance
RegionExitDistance = 75.0 // Distance to exit a region
// Performance limits
MaxRegionsPerZone = 1000 // Maximum regions per zone
MaxPlayersPerRegion = 200 // Maximum players per region
MaxRegionChecksPerTick = 100 // Maximum region checks per processing tick
// File loading constants
MaxRegionFileSize = 10485760 // 10MB maximum region file size
RegionFileBufferSize = 65536 // Buffer size for reading region files
// Cache settings
RegionCacheSize = 500 // Maximum cached region lookups
RegionCacheExpiryMs = 60000 // Region cache expiry in milliseconds
// Statistics update intervals
StatsUpdateInterval = 5000 // Stats update interval in milliseconds
)
// Region file extensions and types
const (
RegionFileExtension = ".region"
WaterVolumeType = "watervol"
WaterRegionType = "waterregion"
WaterRegion2Type = "water_region"
OceanType = "ocean"
WaterType = "water"
)
// Default environment names for different region types
const (
EnvironmentNormal = "normal"
EnvironmentWater = "water"
EnvironmentLava = "lava"
EnvironmentZoneLine = "zoneline"
EnvironmentPVP = "pvp"
EnvironmentSlime = "slime"
EnvironmentIce = "ice"
EnvironmentVWater = "vwater"
)
// Region validation constants
const (
MinRegionSize = 1.0 // Minimum region bounding box size
MaxRegionSize = 10000.0 // Maximum region bounding box size
RegionOverlapTolerance = 0.1 // Tolerance for region overlap detection
)
// Region processing priorities
const (
PriorityLow = 1
PriorityNormal = 2
PriorityHigh = 3
PriorityUrgent = 4
)
// Special region IDs
const (
InvalidRegionID = -1
DefaultRegionID = 0
GlobalRegionID = 999999
)

View File

@ -0,0 +1,193 @@
package region
// RegionZoneIntegration defines the interface for zone integration
type RegionZoneIntegration interface {
// GetZoneName returns the zone name
GetZoneName() string
// GetZoneID returns the zone ID
GetZoneID() int32
// IsValidPosition checks if a position is valid in the zone
IsValidPosition(position [3]float32) bool
// GetSpawnPosition returns the position of a spawn
GetSpawnPosition(spawnID int32) ([3]float32, bool)
// GetClientPosition returns the position of a client
GetClientPosition(clientID int32) ([3]float32, bool)
// GetClientVersion returns the client version
GetClientVersion(clientID int32) int32
}
// RegionClientIntegration defines the interface for client notifications
type RegionClientIntegration interface {
// SendRegionUpdate sends region updates to a client
SendRegionUpdate(clientID int32, regionType WaterRegionType, position [3]float32)
// SendEnvironmentUpdate sends environment changes to a client
SendEnvironmentUpdate(clientID int32, environmentName string)
// NotifyRegionEnter notifies a client they entered a region
NotifyRegionEnter(clientID int32, regionName string, regionType WaterRegionType)
// NotifyRegionLeave notifies a client they left a region
NotifyRegionLeave(clientID int32, regionName string, regionType WaterRegionType)
}
// RegionSpawnIntegration defines the interface for spawn interactions
type RegionSpawnIntegration interface {
// ApplyRegionEffects applies region effects to a spawn
ApplyRegionEffects(spawnID int32, regionType WaterRegionType, environmentName string)
// RemoveRegionEffects removes region effects from a spawn
RemoveRegionEffects(spawnID int32, regionType WaterRegionType)
// GetSpawnMovementSpeed returns the movement speed of a spawn
GetSpawnMovementSpeed(spawnID int32) float32
// SetSpawnMovementSpeed sets the movement speed of a spawn
SetSpawnMovementSpeed(spawnID int32, speed float32)
// IsSpawnSwimming checks if a spawn is swimming
IsSpawnSwimming(spawnID int32) bool
// SetSpawnSwimming sets the swimming state of a spawn
SetSpawnSwimming(spawnID int32, swimming bool)
}
// RegionEventHandler defines the interface for region event handling
type RegionEventHandler interface {
// OnRegionEnter is called when an entity enters a region
OnRegionEnter(event *RegionEvent)
// OnRegionLeave is called when an entity leaves a region
OnRegionLeave(event *RegionEvent)
// OnRegionUpdate is called when region properties are updated
OnRegionUpdate(event *RegionEvent)
// OnEnvironmentChange is called when environment changes
OnEnvironmentChange(event *RegionEvent)
}
// RegionDatabase defines the interface for region data persistence
type RegionDatabase interface {
// LoadRegionData loads region data for a zone
LoadRegionData(zoneName string) ([]*RegionNode, error)
// SaveRegionData saves region data for a zone
SaveRegionData(zoneName string, regions []*RegionNode) error
// LoadRegionMap loads a region map file
LoadRegionMap(filename string) (RegionMap, error)
// GetRegionFiles returns available region files for a zone
GetRegionFiles(zoneName string) ([]string, error)
}
// RegionAdapter provides integration with various zone systems
type RegionAdapter struct {
zoneIntegration RegionZoneIntegration
clientIntegration RegionClientIntegration
spawnIntegration RegionSpawnIntegration
eventHandler RegionEventHandler
database RegionDatabase
}
// NewRegionAdapter creates a new region adapter
func NewRegionAdapter(
zone RegionZoneIntegration,
client RegionClientIntegration,
spawn RegionSpawnIntegration,
events RegionEventHandler,
db RegionDatabase,
) *RegionAdapter {
return &RegionAdapter{
zoneIntegration: zone,
clientIntegration: client,
spawnIntegration: spawn,
eventHandler: events,
database: db,
}
}
// GetZoneIntegration returns the zone integration interface
func (ra *RegionAdapter) GetZoneIntegration() RegionZoneIntegration {
return ra.zoneIntegration
}
// GetClientIntegration returns the client integration interface
func (ra *RegionAdapter) GetClientIntegration() RegionClientIntegration {
return ra.clientIntegration
}
// GetSpawnIntegration returns the spawn integration interface
func (ra *RegionAdapter) GetSpawnIntegration() RegionSpawnIntegration {
return ra.spawnIntegration
}
// GetEventHandler returns the event handler interface
func (ra *RegionAdapter) GetEventHandler() RegionEventHandler {
return ra.eventHandler
}
// GetDatabase returns the database interface
func (ra *RegionAdapter) GetDatabase() RegionDatabase {
return ra.database
}
// RegionLoader defines the interface for loading region data
type RegionLoader interface {
// LoadRegionMapFile loads a region map from a file
LoadRegionMapFile(filename, zoneName string) (RegionMap, error)
// GetSupportedFormats returns supported region file formats
GetSupportedFormats() []string
// ValidateRegionFile validates a region file
ValidateRegionFile(filename string) error
}
// ContainsPosition checks if a bounding box contains a position
func (bb *BoundingBox) ContainsPosition(position [3]float32) bool {
return position[0] >= bb.MinX && position[0] <= bb.MaxX &&
position[1] >= bb.MinY && position[1] <= bb.MaxY &&
position[2] >= bb.MinZ && position[2] <= bb.MaxZ
}
// GetCenter returns the center point of the bounding box
func (bb *BoundingBox) GetCenter() [3]float32 {
return [3]float32{
(bb.MinX + bb.MaxX) * 0.5,
(bb.MinY + bb.MaxY) * 0.5,
(bb.MinZ + bb.MaxZ) * 0.5,
}
}
// GetSize returns the size of the bounding box
func (bb *BoundingBox) GetSize() [3]float32 {
return [3]float32{
bb.MaxX - bb.MinX,
bb.MaxY - bb.MinY,
bb.MaxZ - bb.MinZ,
}
}
// Expand expands the bounding box by the given amount
func (bb *BoundingBox) Expand(amount float32) {
bb.MinX -= amount
bb.MinY -= amount
bb.MinZ -= amount
bb.MaxX += amount
bb.MaxY += amount
bb.MaxZ += amount
}
// Intersects checks if this bounding box intersects with another
func (bb *BoundingBox) Intersects(other *BoundingBox) bool {
return bb.MinX <= other.MaxX && bb.MaxX >= other.MinX &&
bb.MinY <= other.MaxY && bb.MaxY >= other.MinY &&
bb.MinZ <= other.MaxZ && bb.MaxZ >= other.MinZ
}

View File

@ -0,0 +1,335 @@
package region
import (
"fmt"
"log"
"math"
"sync/atomic"
"time"
)
// NewRegionManager creates a new region manager for a zone
func NewRegionManager(zoneName string) *RegionManager {
return &RegionManager{
zoneName: zoneName,
regionMaps: NewRegionMapRange(zoneName),
activeNodes: make(map[string]*RegionNode),
playerRegions: make(map[int32]map[string]bool),
}
}
// AddVersionRange adds a version-specific region map
func (rm *RegionManager) AddVersionRange(minVersion, maxVersion int32, regionMap RegionMap) error {
return rm.regionMaps.AddVersionRange(minVersion, maxVersion, regionMap)
}
// GetRegionMap returns the appropriate region map for a client version
func (rm *RegionManager) GetRegionMap(version int32) RegionMap {
return rm.regionMaps.FindRegionByVersion(version)
}
// ReturnRegionType returns the region type at the given location for a client version
func (rm *RegionManager) ReturnRegionType(location [3]float32, gridID int32, version int32) WaterRegionType {
startTime := time.Now()
defer func() {
atomic.AddInt64(&rm.regionChecks, 1)
// Update average check time (simplified moving average)
// This would be implemented in a more sophisticated stats system
}()
regionMap := rm.GetRegionMap(version)
if regionMap == nil {
return RegionTypeNormal
}
return regionMap.ReturnRegionType(location, gridID)
}
// InWater checks if the location is in water for a client version
func (rm *RegionManager) InWater(location [3]float32, gridID int32, version int32) bool {
regionMap := rm.GetRegionMap(version)
if regionMap == nil {
return false
}
return regionMap.InWater(location, gridID)
}
// InLava checks if the location is in lava for a client version
func (rm *RegionManager) InLava(location [3]float32, gridID int32, version int32) bool {
regionMap := rm.GetRegionMap(version)
if regionMap == nil {
return false
}
return regionMap.InLava(location, gridID)
}
// InLiquid checks if the location is in any liquid for a client version
func (rm *RegionManager) InLiquid(location [3]float32, version int32) bool {
regionMap := rm.GetRegionMap(version)
if regionMap == nil {
return false
}
return regionMap.InLiquid(location)
}
// InPvP checks if the location is in a PvP area for a client version
func (rm *RegionManager) InPvP(location [3]float32, version int32) bool {
regionMap := rm.GetRegionMap(version)
if regionMap == nil {
return false
}
return regionMap.InPvP(location)
}
// InZoneLine checks if the location is in a zone line for a client version
func (rm *RegionManager) InZoneLine(location [3]float32, version int32) bool {
regionMap := rm.GetRegionMap(version)
if regionMap == nil {
return false
}
return regionMap.InZoneLine(location)
}
// AddRegionNode adds a new region node
func (rm *RegionManager) AddRegionNode(name, envName string, gridID, triggerWidgetID uint32, distance float32, position [3]float32) error {
rm.mutex.Lock()
defer rm.mutex.Unlock()
if _, exists := rm.activeNodes[name]; exists {
return fmt.Errorf("region node '%s' already exists", name)
}
node := &RegionNode{
Name: name,
EnvironmentName: envName,
GridID: gridID,
TriggerWidgetID: triggerWidgetID,
Distance: distance,
Position: position,
Active: true,
PlayerList: make(map[int32]bool),
}
rm.activeNodes[name] = node
atomic.AddInt32(&rm.activeRegions, 1)
log.Printf("[Region] Added region node '%s' at position (%.2f, %.2f, %.2f) in zone '%s'",
name, position[0], position[1], position[2], rm.zoneName)
return nil
}
// RemoveRegionNode removes a region node by name
func (rm *RegionManager) RemoveRegionNode(name string) error {
rm.mutex.Lock()
defer rm.mutex.Unlock()
node, exists := rm.activeNodes[name]
if !exists {
return fmt.Errorf("region node '%s' not found", name)
}
// Remove players from this region
node.mutex.Lock()
for playerID := range node.PlayerList {
rm.removePlayerFromRegion(playerID, name)
}
node.mutex.Unlock()
delete(rm.activeNodes, name)
atomic.AddInt32(&rm.activeRegions, -1)
log.Printf("[Region] Removed region node '%s' from zone '%s'", name, rm.zoneName)
return nil
}
// UpdatePlayerRegions updates the regions for a player based on their position
func (rm *RegionManager) UpdatePlayerRegions(playerID int32, position [3]float32, version int32) {
rm.mutex.RLock()
currentRegions := make(map[string]bool)
if playerRegions, exists := rm.playerRegions[playerID]; exists {
for regionName := range playerRegions {
currentRegions[regionName] = true
}
}
rm.mutex.RUnlock()
newRegions := make(map[string]bool)
// Check all active region nodes
rm.mutex.RLock()
for regionName, node := range rm.activeNodes {
if !node.Active {
continue
}
// Calculate distance to region
dx := position[0] - node.Position[0]
dy := position[1] - node.Position[1]
dz := position[2] - node.Position[2]
distance := float32(math.Sqrt(float64(dx*dx + dy*dy + dz*dz)))
// Check if player is within region
if distance <= node.Distance {
newRegions[regionName] = true
// Add player to region if not already there
if !currentRegions[regionName] {
rm.addPlayerToRegion(playerID, regionName, position)
}
}
}
rm.mutex.RUnlock()
// Remove player from regions they've left
for regionName := range currentRegions {
if !newRegions[regionName] {
rm.removePlayerFromRegion(playerID, regionName)
}
}
// Update player's region list
rm.mutex.Lock()
if len(newRegions) > 0 {
rm.playerRegions[playerID] = newRegions
} else {
delete(rm.playerRegions, playerID)
}
rm.mutex.Unlock()
}
// RemovePlayer removes a player from all regions
func (rm *RegionManager) RemovePlayer(playerID int32) {
rm.mutex.Lock()
defer rm.mutex.Unlock()
if playerRegions, exists := rm.playerRegions[playerID]; exists {
for regionName := range playerRegions {
if node, nodeExists := rm.activeNodes[regionName]; nodeExists {
node.mutex.Lock()
delete(node.PlayerList, playerID)
node.mutex.Unlock()
}
}
delete(rm.playerRegions, playerID)
}
}
// GetActiveRegions returns a list of active region names
func (rm *RegionManager) GetActiveRegions() []string {
rm.mutex.RLock()
defer rm.mutex.RUnlock()
regions := make([]string, 0, len(rm.activeNodes))
for name, node := range rm.activeNodes {
if node.Active {
regions = append(regions, name)
}
}
return regions
}
// GetPlayersInRegion returns a list of player IDs in the specified region
func (rm *RegionManager) GetPlayersInRegion(regionName string) []int32 {
rm.mutex.RLock()
node, exists := rm.activeNodes[regionName]
rm.mutex.RUnlock()
if !exists {
return nil
}
node.mutex.RLock()
defer node.mutex.RUnlock()
players := make([]int32, 0, len(node.PlayerList))
for playerID := range node.PlayerList {
players = append(players, playerID)
}
return players
}
// GetPlayerRegions returns the regions a player is currently in
func (rm *RegionManager) GetPlayerRegions(playerID int32) []string {
rm.mutex.RLock()
defer rm.mutex.RUnlock()
if playerRegions, exists := rm.playerRegions[playerID]; exists {
regions := make([]string, 0, len(playerRegions))
for regionName := range playerRegions {
regions = append(regions, regionName)
}
return regions
}
return nil
}
// GetStats returns current region statistics
func (rm *RegionManager) GetStats() *RegionStats {
rm.mutex.RLock()
defer rm.mutex.RUnlock()
return &RegionStats{
TotalRegionChecks: atomic.LoadInt64(&rm.regionChecks),
TotalRegionTransitions: atomic.LoadInt64(&rm.regionTransitions),
ActiveRegions: atomic.LoadInt32(&rm.activeRegions),
ActivePlayers: int32(len(rm.playerRegions)),
RegionMapsLoaded: int32(rm.regionMaps.GetLoadedMapCount()),
AverageCheckTime: 0.0, // Would be calculated from timing data
}
}
// ResetStats resets all region statistics
func (rm *RegionManager) ResetStats() {
atomic.StoreInt64(&rm.regionChecks, 0)
atomic.StoreInt64(&rm.regionTransitions, 0)
}
// GetZoneName returns the zone name for this region manager
func (rm *RegionManager) GetZoneName() string {
return rm.zoneName
}
// Private methods
func (rm *RegionManager) addPlayerToRegion(playerID int32, regionName string, position [3]float32) {
rm.mutex.RLock()
node, exists := rm.activeNodes[regionName]
rm.mutex.RUnlock()
if exists {
node.mutex.Lock()
node.PlayerList[playerID] = true
node.mutex.Unlock()
atomic.AddInt64(&rm.regionTransitions, 1)
log.Printf("[Region] Player %d entered region '%s' at position (%.2f, %.2f, %.2f)",
playerID, regionName, position[0], position[1], position[2])
}
}
func (rm *RegionManager) removePlayerFromRegion(playerID int32, regionName string) {
rm.mutex.RLock()
node, exists := rm.activeNodes[regionName]
rm.mutex.RUnlock()
if exists {
node.mutex.Lock()
delete(node.PlayerList, playerID)
node.mutex.Unlock()
atomic.AddInt64(&rm.regionTransitions, 1)
log.Printf("[Region] Player %d left region '%s'", playerID, regionName)
}
}

View File

@ -0,0 +1,227 @@
package region
import (
"fmt"
"log"
"sync"
)
// NewRegionMapRange creates a new region map range for a zone
func NewRegionMapRange(zoneName string) *RegionMapRange {
return &RegionMapRange{
name: zoneName,
versionMap: make(map[*VersionRange]RegionMap),
}
}
// AddVersionRange adds a version-specific region map
func (rmr *RegionMapRange) AddVersionRange(minVersion, maxVersion int32, regionMap RegionMap) error {
rmr.mutex.Lock()
defer rmr.mutex.Unlock()
if regionMap == nil {
return fmt.Errorf("region map cannot be nil")
}
versionRange := &VersionRange{
MinVersion: minVersion,
MaxVersion: maxVersion,
}
rmr.versionMap[versionRange] = regionMap
log.Printf("[Region] Added version range [%d-%d] for zone '%s'",
minVersion, maxVersion, rmr.name)
return nil
}
// FindVersionRange finds a region map that supports the given version range
func (rmr *RegionMapRange) FindVersionRange(minVersion, maxVersion int32) (RegionMap, bool) {
rmr.mutex.RLock()
defer rmr.mutex.RUnlock()
for versionRange, regionMap := range rmr.versionMap {
// If min and max version are both in range
if versionRange.MinVersion <= minVersion && maxVersion <= versionRange.MaxVersion {
return regionMap, true
}
// If the min version is in range, but max range is 0 (unlimited)
if versionRange.MinVersion <= minVersion && versionRange.MaxVersion == 0 {
return regionMap, true
}
// If min version is 0 and max_version has a cap
if versionRange.MinVersion == 0 && maxVersion <= versionRange.MaxVersion {
return regionMap, true
}
}
return nil, false
}
// FindRegionByVersion finds a region map for a specific client version
func (rmr *RegionMapRange) FindRegionByVersion(version int32) RegionMap {
rmr.mutex.RLock()
defer rmr.mutex.RUnlock()
var fallbackMap RegionMap
for versionRange, regionMap := range rmr.versionMap {
// If min and max version are both 0, this is a fallback map
if versionRange.MinVersion == 0 && versionRange.MaxVersion == 0 {
fallbackMap = regionMap
continue
}
// Check if version is in range
if version >= versionRange.MinVersion {
// If MaxVersion is 0, it means unlimited
if versionRange.MaxVersion == 0 || version <= versionRange.MaxVersion {
return regionMap
}
}
}
// Return fallback map if no specific version match
return fallbackMap
}
// GetAllVersionRanges returns all version ranges and their associated region maps
func (rmr *RegionMapRange) GetAllVersionRanges() map[*VersionRange]RegionMap {
rmr.mutex.RLock()
defer rmr.mutex.RUnlock()
// Return a copy to prevent external modification
result := make(map[*VersionRange]RegionMap)
for vr, rm := range rmr.versionMap {
result[vr] = rm
}
return result
}
// GetLoadedMapCount returns the number of loaded region maps
func (rmr *RegionMapRange) GetLoadedMapCount() int {
rmr.mutex.RLock()
defer rmr.mutex.RUnlock()
return len(rmr.versionMap)
}
// RemoveVersionRange removes a version range by its min/max versions
func (rmr *RegionMapRange) RemoveVersionRange(minVersion, maxVersion int32) bool {
rmr.mutex.Lock()
defer rmr.mutex.Unlock()
for versionRange := range rmr.versionMap {
if versionRange.MinVersion == minVersion && versionRange.MaxVersion == maxVersion {
delete(rmr.versionMap, versionRange)
log.Printf("[Region] Removed version range [%d-%d] for zone '%s'",
minVersion, maxVersion, rmr.name)
return true
}
}
return false
}
// Clear removes all version ranges and region maps
func (rmr *RegionMapRange) Clear() {
rmr.mutex.Lock()
defer rmr.mutex.Unlock()
// Clear the map
for vr := range rmr.versionMap {
delete(rmr.versionMap, vr)
}
log.Printf("[Region] Cleared all version ranges for zone '%s'", rmr.name)
}
// GetName returns the zone name for this region map range
func (rmr *RegionMapRange) GetName() string {
return rmr.name
}
// GetMinVersion returns the minimum version supported by any region map
func (rmr *RegionMapRange) GetMinVersion() int32 {
rmr.mutex.RLock()
defer rmr.mutex.RUnlock()
if len(rmr.versionMap) == 0 {
return 0
}
minVersion := int32(999999) // Start with a high value
for versionRange := range rmr.versionMap {
if versionRange.MinVersion > 0 && versionRange.MinVersion < minVersion {
minVersion = versionRange.MinVersion
}
}
if minVersion == 999999 {
return 0 // No valid minimum found
}
return minVersion
}
// GetMaxVersion returns the maximum version supported by any region map
func (rmr *RegionMapRange) GetMaxVersion() int32 {
rmr.mutex.RLock()
defer rmr.mutex.RUnlock()
if len(rmr.versionMap) == 0 {
return 0
}
maxVersion := int32(0)
for versionRange := range rmr.versionMap {
if versionRange.MaxVersion > maxVersion {
maxVersion = versionRange.MaxVersion
}
}
return maxVersion
}
// NewVersionRange creates a new version range
func NewVersionRange(minVersion, maxVersion int32) *VersionRange {
return &VersionRange{
MinVersion: minVersion,
MaxVersion: maxVersion,
}
}
// GetMinVersion returns the minimum version for this range
func (vr *VersionRange) GetMinVersion() int32 {
return vr.MinVersion
}
// GetMaxVersion returns the maximum version for this range
func (vr *VersionRange) GetMaxVersion() int32 {
return vr.MaxVersion
}
// ContainsVersion checks if a version is within this range
func (vr *VersionRange) ContainsVersion(version int32) bool {
if vr.MinVersion > 0 && version < vr.MinVersion {
return false
}
if vr.MaxVersion > 0 && version > vr.MaxVersion {
return false
}
return true
}
// String returns a string representation of the version range
func (vr *VersionRange) String() string {
if vr.MaxVersion == 0 {
return fmt.Sprintf("[%d+]", vr.MinVersion)
}
return fmt.Sprintf("[%d-%d]", vr.MinVersion, vr.MaxVersion)
}

View File

@ -0,0 +1,154 @@
package region
import (
"sync"
)
// WaterRegionType defines different types of regions
type WaterRegionType int32
const (
RegionTypeUnsupported WaterRegionType = -2
RegionTypeUntagged WaterRegionType = -1
RegionTypeNormal WaterRegionType = 0
RegionTypeWater WaterRegionType = 1
RegionTypeLava WaterRegionType = 2
RegionTypeZoneLine WaterRegionType = 3
RegionTypePVP WaterRegionType = 4
RegionTypeSlime WaterRegionType = 5
RegionTypeIce WaterRegionType = 6
RegionTypeVWater WaterRegionType = 7
)
// WaterRegionClass defines different classes of water regions
type WaterRegionClass int32
const (
ClassWaterVolume WaterRegionClass = 0 // matching .region file type by name "watervol"
ClassWaterRegion WaterRegionClass = 1 // matching .region file type by name "waterregion"
ClassWaterRegion2 WaterRegionClass = 2 // represents .region file name "water_region" potentially defunct
ClassWaterOcean WaterRegionClass = 3 // represents .region file with "ocean" and a select node as a parent
ClassWaterCavern WaterRegionClass = 4 // represents .region file with matches on name "ocean" and "water"
ClassWaterOcean2 WaterRegionClass = 5 // represents .region file with matches on name "ocean"
)
// VersionRange represents a client version range for region maps
type VersionRange struct {
MinVersion int32 // Minimum client version
MaxVersion int32 // Maximum client version (0 = no maximum)
}
// RegionNode represents a region with environmental effects
type RegionNode struct {
Name string // Region name
EnvironmentName string // Environment name for effects
GridID uint32 // Associated grid ID
TriggerWidgetID uint32 // Widget that triggers this region
Distance float32 // Trigger distance
Position [3]float32 // Region center position
BoundingBox *BoundingBox // Region bounding box
Active bool // Whether this region is active
PlayerList map[int32]bool // Players currently in this region
mutex sync.RWMutex // Thread safety for player list
}
// BoundingBox represents a 3D bounding box for regions
type BoundingBox struct {
MinX, MinY, MinZ float32
MaxX, MaxY, MaxZ float32
}
// RegionMap defines the interface for region mapping implementations
type RegionMap interface {
// ReturnRegionType returns the region type at the given location
ReturnRegionType(location [3]float32, gridID int32) WaterRegionType
// InWater checks if the location is in water
InWater(location [3]float32, gridID int32) bool
// InLava checks if the location is in lava
InLava(location [3]float32, gridID int32) bool
// InLiquid checks if the location is in any liquid
InLiquid(location [3]float32) bool
// InPvP checks if the location is in a PvP area
InPvP(location [3]float32) bool
// InZoneLine checks if the location is in a zone line
InZoneLine(location [3]float32) bool
// IdentifyRegionsInGrid identifies all regions in a grid for a client
IdentifyRegionsInGrid(clientID int32, location [3]float32)
// MapRegionsNearSpawn maps regions near a spawn
MapRegionsNearSpawn(spawnID int32, clientID int32)
// UpdateRegionsNearSpawn updates regions near a spawn
UpdateRegionsNearSpawn(spawnID int32, clientID int32)
// TicRegionsNearSpawn processes region effects for spawns
TicRegionsNearSpawn(spawnID int32, clientID int32)
// InsertRegionNode inserts a new region node
InsertRegionNode(version int32, regionName, envName string, gridID, triggerWidgetID uint32, dist float32)
// RemoveRegionNode removes a region node by name
RemoveRegionNode(regionName string)
// Load loads region data from a file
Load(filename string) error
}
// RegionMapRange manages region maps for different client versions
type RegionMapRange struct {
name string // Zone name
versionMap map[*VersionRange]RegionMap // Version-specific region maps
mutex sync.RWMutex // Thread safety
}
// RegionManager manages all regions for a zone
type RegionManager struct {
zoneName string // Zone name
regionMaps *RegionMapRange // Version-specific region maps
activeNodes map[string]*RegionNode // Active region nodes
playerRegions map[int32]map[string]bool // Player -> Region mapping
mutex sync.RWMutex // Thread safety
// Statistics
regionChecks int64 // Total region checks
regionTransitions int64 // Total region transitions
activeRegions int32 // Currently active regions
}
// RegionEvent represents a region-related event
type RegionEvent struct {
Type RegionEventType // Event type
PlayerID int32 // Player ID
SpawnID int32 // Spawn ID (if applicable)
RegionName string // Region name
RegionType WaterRegionType // Region type
Position [3]float32 // Position where event occurred
Timestamp int64 // Event timestamp
EnterRegion bool // True if entering, false if leaving
}
// RegionEventType defines types of region events
type RegionEventType int32
const (
RegionEventEnter RegionEventType = iota
RegionEventLeave
RegionEventUpdate
RegionEventEnvironment
)
// RegionStats contains region system statistics
type RegionStats struct {
TotalRegionChecks int64 `json:"total_region_checks"`
TotalRegionTransitions int64 `json:"total_region_transitions"`
ActiveRegions int32 `json:"active_regions"`
ActivePlayers int32 `json:"active_players"`
RegionMapsLoaded int32 `json:"region_maps_loaded"`
AverageCheckTime float64 `json:"average_check_time_ms"`
}

603
internal/zone/types.go Normal file
View File

@ -0,0 +1,603 @@
package zone
import (
"sync"
"sync/atomic"
"time"
"eq2emu/internal/common"
"eq2emu/internal/spawn"
)
// Instance types define different zone instance behaviors
type InstanceType int16
const (
InstanceTypeNone InstanceType = iota
InstanceTypeGroupLockout
InstanceTypeGroupPersist
InstanceTypeRaidLockout
InstanceTypeRaidPersist
InstanceTypeSoloLockout
InstanceTypeSoloPersist
InstanceTypeTradeskill
InstanceTypePublic
InstanceTypePersonalHouse
InstanceTypeGuildHouse
InstanceTypeQuest
)
// Core zone server structure - equivalent to C++ ZoneServer class
type ZoneServer struct {
// Zone identity and configuration
zoneID int32
instanceID int32
zoneName string
zoneFile string
zoneSkyFile string
zoneDescription string
// Zone properties
cityZone bool
alwaysLoaded bool
duplicatedZone bool
duplicatedID int32
locked bool
isInstance bool
instanceType InstanceType
// Safe zone coordinates
safeX float32
safeY float32
safeZ float32
safeHeading float32
underworld float32
// Zone modifiers and rules
xpModifier float32
minimumStatus int16
minimumLevel int16
maximumLevel int16
minimumVersion int16
expansionFlag int32
holidayFlag int32
canBind bool
canGate bool
canEvac bool
// Zone times and lockouts
defaultLockoutTime int32
defaultReenterTime int32
defaultResetTime int32
groupZoneOption int8
// Player and client management
clients []Client
numPlayers int32
incomingClients int32
lifetimeClientCount int32
// Weather system
weatherEnabled bool
weatherAllowed bool
weatherType int8
weatherFrequency int32
weatherMinSeverity float32
weatherMaxSeverity float32
weatherChangeAmount float32
weatherDynamicOffset float32
weatherChangeChance int8
weatherCurrentSeverity float32
weatherPattern int8
weatherLastChangedTime int32
weatherSignaled bool
rain float32
// Time system
isDusk bool
duskHour int
duskMinute int
dawnHour int
dawnMinute int
// Zone message
zoneMOTD string
// Thread and processing state
spawnThreadActive bool
combatThreadActive bool
initialSpawnThreads int8
clientThreadActive bool
loadingData bool
zoneShuttingDown atomic.Bool
isInitialized atomic.Bool
finishedDepop bool
depopZone bool
repopZone bool
respawnsAllowed bool
// Spawn management
spawnList map[int32]*spawn.Spawn
spawnLocationList map[int32]*SpawnLocation
changedSpawns map[int32]bool
damagedSpawns []int32
pendingSpawnList []*spawn.Spawn
pendingSpawnRemove map[int32]bool
spawnDeleteList map[*spawn.Spawn]int32
spawnExpireTimers map[int32]int32
// Grid system for spatial optimization
gridMaps map[int32]*GridMap
// Movement and pathfinding
pathfinder IPathfinder
movementMgr *MobMovementManager
// System components
spellProcess SpellProcess
tradeskillMgr TradeskillManager
// Timers
aggroTimer *common.Timer
charsheetChanges *common.Timer
clientSave *common.Timer
locationProxTimer *common.Timer
movementTimer *common.Timer
regenTimer *common.Timer
respawnTimer *common.Timer
shutdownTimer *common.Timer
spawnRangeTimer *common.Timer
spawnUpdateTimer *common.Timer
syncGameTimer *common.Timer
trackingTimer *common.Timer
weatherTimer *common.Timer
widgetTimer *common.Timer
// Proximity systems
playerProximities map[int32]*PlayerProximity
locationProximities []*LocationProximity
locationGrids []*LocationGrid
// Faction management
enemyFactionList map[int32][]int32
npcFactionList map[int32][]int32
reverseEnemyFactionList map[int32][]int32
// Revive points
revivePoints []*RevivePoint
// Transport system
transportSpawns []int32
transportLocations []*LocationTransportDestination
transporters map[int32][]*TransportDestination
locationTransporters map[int32][]*LocationTransportDestination
transportMaps map[int32]string
// Flight paths
flightPaths map[int32]*FlightPathInfo
flightPathRoutes map[int32][]*FlightPathLocation
// Loot system
lootTables map[int32]*LootTable
lootDrops map[int32][]*LootDrop
spawnLootList map[int32][]int32
levelLootList []*GlobalLoot
racialLootList map[int16][]*GlobalLoot
zoneLootList map[int32][]*GlobalLoot
// Spawn data caches
npcList map[int32]*NPC
objectList map[int32]*Object
signList map[int32]*Sign
widgetList map[int32]*Widget
groundSpawnList map[int32]*GroundSpawn
// Entity commands
entityCommandList map[int32][]*EntityCommand
// Spawn groups and locations
spawnGroupAssociations map[int32][]int32
spawnGroupChances map[int32]float32
spawnGroupLocations map[int32]map[int32]int32
spawnGroupMap map[int32][]int32
spawnLocationGroups map[int32][]int32
// Script timers
spawnScriptTimers []*SpawnScriptTimer
// Widget management
widgetTimers map[int32]int32
ignoredWidgets map[int32]bool
// House items
subspawnList map[SubspawnType]map[int32]*spawn.Spawn
housingSpawnMap map[int32]int32
// Respawn management
respawnTimers map[int32]int32
// Zone map
defaultZoneMap Map
// Group/raid level tracking
groupRaidMinLevel int32
groupRaidMaxLevel int32
groupRaidAvgLevel int32
groupRaidFirstLevel int32
// Watchdog
watchdogTimestamp int32
// Lua command queues
luaQueuedStateCommands map[int32]int32
luaSpawnUpdateCommands map[int32]map[string]float32
// Mutexes for thread safety
masterZoneLock sync.RWMutex
masterSpawnLock sync.RWMutex
spawnListLock sync.RWMutex
clientListLock sync.RWMutex
changedSpawnsLock sync.RWMutex
gridMapsLock sync.RWMutex
spawnLocationListLock sync.RWMutex
spawnDeleteListLock sync.RWMutex
widgetTimersLock sync.RWMutex
ignoredWidgetsLock sync.RWMutex
pendingSpawnRemoveLock sync.RWMutex
luaQueueStateCmdLock sync.Mutex
transportLock sync.RWMutex
factionLock sync.RWMutex
spawnScriptTimersLock sync.RWMutex
deadSpawnsLock sync.RWMutex
incomingClientsLock sync.RWMutex
}
// PlayerProximity manages spawn proximity events for Lua scripting
type PlayerProximity struct {
Distance float32
InRangeLuaFunction string
LeavingRangeLuaFunction string
ClientsInProximity map[Client]bool
mutex sync.RWMutex
}
// LocationProximity handles location-based proximity triggers
type LocationProximity struct {
X float32
Y float32
Z float32
MaxVariation float32
InRangeLuaFunction string
LeavingRangeLuaFunction string
ClientsInProximity map[Client]bool
mutex sync.RWMutex
}
// LocationGrid manages discovery grids and player tracking
type LocationGrid struct {
ID int32
GridID int32
Name string
IncludeY bool
Discovery bool
Locations []*Location
Players map[Player]bool
mutex sync.RWMutex
}
// GridMap provides spawn organization by grid for efficient querying
type GridMap struct {
GridID int32
Spawns map[int32]*spawn.Spawn
mutex sync.RWMutex
}
// TrackedSpawn represents distance-based spawn tracking for clients
type TrackedSpawn struct {
Spawn *spawn.Spawn
Distance float32
}
// HouseItem represents player housing item management
type HouseItem struct {
SpawnID int32
ItemID int32
UniqueID int64
Item *Item
}
// RevivePoint represents death recovery locations
type RevivePoint struct {
ID int32
ZoneID int32
LocationName string
X float32
Y float32
Z float32
Heading float32
AlwaysIncluded bool
}
// SpawnScriptTimer represents Lua script timer management
type SpawnScriptTimer struct {
Timer int32
SpawnID int32
PlayerID int32
Function string
CurrentCount int32
MaxCount int32
}
// FlightPathInfo contains flight path configuration
type FlightPathInfo struct {
Speed float32
Flying bool
Dismount bool
}
// FlightPathLocation represents a waypoint in a flight path
type FlightPathLocation struct {
X float32
Y float32
Z float32
}
// ZoneInfoSlideStructInfo represents zone intro slide information
type ZoneInfoSlideStructInfo struct {
Unknown1 [2]float32
Unknown2 [2]int32
Unknown3 int32
Unknown4 int32
Slide string
Voiceover string
Key1 int32
Key2 int32
}
// ZoneInfoSlideStructTransitionInfo represents slide transition data
type ZoneInfoSlideStructTransitionInfo struct {
TransitionX int32
TransitionY int32
TransitionZoom float32
TransitionTime float32
}
// ZoneInfoSlideStruct combines slide info and transitions
type ZoneInfoSlideStruct struct {
Info *ZoneInfoSlideStructInfo
SlideTransitionInfo []*ZoneInfoSlideStructTransitionInfo
}
// SubspawnType enumeration for different spawn subtypes
type SubspawnType int
const (
SubspawnTypeCollector SubspawnType = iota
SubspawnTypeHouseItem
SubspawnTypeMax = 20
)
// Expansion flags for client compatibility
const (
ExpansionUnknown = 1
ExpansionUnknown2 = 64
ExpansionUnknown3 = 128
ExpansionUnknown4 = 256
ExpansionUnknown5 = 512
ExpansionDOF = 1024
ExpansionKOS = 2048
ExpansionEOF = 4096
ExpansionROK = 8192
ExpansionTSO = 16384
ExpansionDOV = 65536
)
// Spawn script event types
const (
SpawnScriptSpawn = iota
SpawnScriptRespawn
SpawnScriptAttacked
SpawnScriptTargeted
SpawnScriptHailed
SpawnScriptDeath
SpawnScriptKilled
SpawnScriptAggro
SpawnScriptHealthChanged
SpawnScriptRandomChat
SpawnScriptConversation
SpawnScriptTimer
SpawnScriptCustom
SpawnScriptHailedBusy
SpawnScriptCastedOn
SpawnScriptAutoAttackTick
SpawnScriptCombatReset
SpawnScriptGroupDead
SpawnScriptHearSay
SpawnScriptPrespawn
SpawnScriptUseDoor
SpawnScriptBoard
SpawnScriptDeboard
)
// Spawn conditional flags
const (
SpawnConditionalNone = 0
SpawnConditionalDay = 1
SpawnConditionalNight = 2
SpawnConditionalNotRaining = 4
SpawnConditionalRaining = 8
)
// Distance constants
const (
SendSpawnDistance = 250.0 // When spawns appear visually to the client
HearSpawnDistance = 30.0 // Max distance a client can be from a spawn to 'hear' it
MaxChaseDistance = 80.0 // Maximum chase distance for NPCs
RemoveSpawnDistance = 300.0 // Distance at which spawns are removed from client
MaxRevivePointDistance = 1000.0 // Maximum distance for revive point selection
)
// Tracking system constants
const (
TrackingStop = iota
TrackingStart
TrackingUpdate
TrackingCloseWindow
)
const (
TrackingTypeEntities = iota + 1
TrackingTypeHarvestables
)
const (
TrackingSpawnTypePC = iota
TrackingSpawnTypeNPC
)
// Waypoint categories
const (
WaypointCategoryGroup = iota
WaypointCategoryQuests
WaypointCategoryPeople
WaypointCategoryPlaces
WaypointCategoryUser
WaypointCategoryDirections
WaypointCategoryTracking
WaypointCategoryHouses
WaypointCategoryMap
)
// Weather system constants
const (
WeatherTypeNormal = iota
WeatherTypeDynamic
WeatherTypeRandom
WeatherTypeChaotic
)
const (
WeatherPatternDecreasing = iota
WeatherPatternIncreasing
WeatherPatternRandom
)
// NewZoneServer creates a new zone server instance with the given zone name
func NewZoneServer(zoneName string) *ZoneServer {
zs := &ZoneServer{
zoneName: zoneName,
respawnsAllowed: true,
finishedDepop: true,
canBind: true,
canGate: true,
canEvac: true,
clients: make([]Client, 0),
spawnList: make(map[int32]*spawn.Spawn),
spawnLocationList: make(map[int32]*SpawnLocation),
changedSpawns: make(map[int32]bool),
damagedSpawns: make([]int32, 0),
pendingSpawnList: make([]*spawn.Spawn, 0),
pendingSpawnRemove: make(map[int32]bool),
spawnDeleteList: make(map[*spawn.Spawn]int32),
spawnExpireTimers: make(map[int32]int32),
gridMaps: make(map[int32]*GridMap),
playerProximities: make(map[int32]*PlayerProximity),
locationProximities: make([]*LocationProximity, 0),
locationGrids: make([]*LocationGrid, 0),
enemyFactionList: make(map[int32][]int32),
npcFactionList: make(map[int32][]int32),
reverseEnemyFactionList: make(map[int32][]int32),
revivePoints: make([]*RevivePoint, 0),
transportSpawns: make([]int32, 0),
transportLocations: make([]*LocationTransportDestination, 0),
transporters: make(map[int32][]*TransportDestination),
locationTransporters: make(map[int32][]*LocationTransportDestination),
transportMaps: make(map[int32]string),
flightPaths: make(map[int32]*FlightPathInfo),
flightPathRoutes: make(map[int32][]*FlightPathLocation),
lootTables: make(map[int32]*LootTable),
lootDrops: make(map[int32][]*LootDrop),
spawnLootList: make(map[int32][]int32),
levelLootList: make([]*GlobalLoot, 0),
racialLootList: make(map[int16][]*GlobalLoot),
zoneLootList: make(map[int32][]*GlobalLoot),
npcList: make(map[int32]*NPC),
objectList: make(map[int32]*Object),
signList: make(map[int32]*Sign),
widgetList: make(map[int32]*Widget),
groundSpawnList: make(map[int32]*GroundSpawn),
entityCommandList: make(map[int32][]*EntityCommand),
spawnGroupAssociations: make(map[int32][]int32),
spawnGroupChances: make(map[int32]float32),
spawnGroupLocations: make(map[int32]map[int32]int32),
spawnGroupMap: make(map[int32][]int32),
spawnLocationGroups: make(map[int32][]int32),
spawnScriptTimers: make([]*SpawnScriptTimer, 0),
widgetTimers: make(map[int32]int32),
ignoredWidgets: make(map[int32]bool),
subspawnList: make(map[SubspawnType]map[int32]*spawn.Spawn),
housingSpawnMap: make(map[int32]int32),
respawnTimers: make(map[int32]int32),
luaQueuedStateCommands: make(map[int32]int32),
luaSpawnUpdateCommands: make(map[int32]map[string]float32),
watchdogTimestamp: int32(time.Now().Unix()),
}
// Initialize subspawn lists
for i := SubspawnType(0); i < SubspawnTypeMax; i++ {
zs.subspawnList[i] = make(map[int32]*spawn.Spawn)
}
zs.loadingData = true
zs.zoneShuttingDown.Store(false)
zs.isInitialized.Store(false)
return zs
}
// String returns a string representation of the instance type
func (it InstanceType) String() string {
switch it {
case InstanceTypeNone:
return "None"
case InstanceTypeGroupLockout:
return "Group Lockout"
case InstanceTypeGroupPersist:
return "Group Persistent"
case InstanceTypeRaidLockout:
return "Raid Lockout"
case InstanceTypeRaidPersist:
return "Raid Persistent"
case InstanceTypeSoloLockout:
return "Solo Lockout"
case InstanceTypeSoloPersist:
return "Solo Persistent"
case InstanceTypeTradeskill:
return "Tradeskill"
case InstanceTypePublic:
return "Public"
case InstanceTypePersonalHouse:
return "Personal House"
case InstanceTypeGuildHouse:
return "Guild House"
case InstanceTypeQuest:
return "Quest"
default:
return "Unknown"
}
}
// IsShuttingDown returns whether the zone is in the process of shutting down
func (zs *ZoneServer) IsShuttingDown() bool {
return zs.zoneShuttingDown.Load()
}
// IsInitialized returns whether the zone has completed initialization
func (zs *ZoneServer) IsInitialized() bool {
return zs.isInitialized.Load()
}
// Shutdown initiates the zone shutdown process
func (zs *ZoneServer) Shutdown() {
zs.zoneShuttingDown.Store(true)
}

View File

@ -0,0 +1,598 @@
package zone
import (
"fmt"
"log"
"sync"
"time"
"eq2emu/internal/database"
)
// ZoneManager manages all active zones in the server
type ZoneManager struct {
zones map[int32]*ZoneServer
zonesByName map[string]*ZoneServer
instanceZones map[int32]*ZoneServer
db *database.Database
config *ZoneManagerConfig
shutdownSignal chan struct{}
isShuttingDown bool
mutex sync.RWMutex
processTimer *time.Ticker
cleanupTimer *time.Ticker
}
// ZoneManagerConfig holds configuration for the zone manager
type ZoneManagerConfig struct {
MaxZones int32
MaxInstanceZones int32
ProcessInterval time.Duration
CleanupInterval time.Duration
DatabasePath string
DefaultMapPath string
EnableWeather bool
EnablePathfinding bool
EnableCombat bool
EnableSpellProcess bool
AutoSaveInterval time.Duration
}
// NewZoneManager creates a new zone manager
func NewZoneManager(config *ZoneManagerConfig, db *database.Database) *ZoneManager {
if config.ProcessInterval == 0 {
config.ProcessInterval = time.Millisecond * 100 // 10 FPS default
}
if config.CleanupInterval == 0 {
config.CleanupInterval = time.Minute * 5 // 5 minutes default
}
if config.MaxZones == 0 {
config.MaxZones = 100
}
if config.MaxInstanceZones == 0 {
config.MaxInstanceZones = 1000
}
zm := &ZoneManager{
zones: make(map[int32]*ZoneServer),
zonesByName: make(map[string]*ZoneServer),
instanceZones: make(map[int32]*ZoneServer),
db: db,
config: config,
shutdownSignal: make(chan struct{}),
}
return zm
}
// Start starts the zone manager and its processing loops
func (zm *ZoneManager) Start() error {
zm.mutex.Lock()
defer zm.mutex.Unlock()
if zm.processTimer != nil {
return fmt.Errorf("zone manager already started")
}
// Start processing timers
zm.processTimer = time.NewTicker(zm.config.ProcessInterval)
zm.cleanupTimer = time.NewTicker(zm.config.CleanupInterval)
// Start processing goroutines
go zm.processLoop()
go zm.cleanupLoop()
log.Printf("%s Zone manager started", LogPrefixZone)
return nil
}
// Stop stops the zone manager and all zones
func (zm *ZoneManager) Stop() error {
zm.mutex.Lock()
defer zm.mutex.Unlock()
if zm.isShuttingDown {
return fmt.Errorf("zone manager already shutting down")
}
zm.isShuttingDown = true
close(zm.shutdownSignal)
// Stop timers
if zm.processTimer != nil {
zm.processTimer.Stop()
zm.processTimer = nil
}
if zm.cleanupTimer != nil {
zm.cleanupTimer.Stop()
zm.cleanupTimer = nil
}
// Shutdown all zones
for _, zone := range zm.zones {
zone.Shutdown()
}
for _, zone := range zm.instanceZones {
zone.Shutdown()
}
log.Printf("%s Zone manager stopped", LogPrefixZone)
return nil
}
// LoadZone loads a zone by ID
func (zm *ZoneManager) LoadZone(zoneID int32) (*ZoneServer, error) {
zm.mutex.Lock()
defer zm.mutex.Unlock()
// Check if zone is already loaded
if zone, exists := zm.zones[zoneID]; exists {
return zone, nil
}
// Check zone limit
if int32(len(zm.zones)) >= zm.config.MaxZones {
return nil, fmt.Errorf("maximum zones reached (%d)", zm.config.MaxZones)
}
// Load zone data from database
zoneDB := NewZoneDatabase(zm.db.DB)
zoneData, err := zoneDB.LoadZoneData(zoneID)
if err != nil {
return nil, fmt.Errorf("failed to load zone data: %v", err)
}
// Create zone server
zoneServer := NewZoneServer(zoneData.Configuration.Name)
// Configure zone server
config := &ZoneServerConfig{
ZoneName: zoneData.Configuration.Name,
ZoneFile: zoneData.Configuration.File,
ZoneDescription: zoneData.Configuration.Description,
ZoneID: zoneID,
InstanceID: 0, // Not an instance
InstanceType: InstanceTypeNone,
DatabasePath: zm.config.DatabasePath,
MaxPlayers: zoneData.Configuration.MaxPlayers,
MinLevel: zoneData.Configuration.MinLevel,
MaxLevel: zoneData.Configuration.MaxLevel,
SafeX: zoneData.Configuration.SafeX,
SafeY: zoneData.Configuration.SafeY,
SafeZ: zoneData.Configuration.SafeZ,
SafeHeading: zoneData.Configuration.SafeHeading,
LoadMaps: true,
EnableWeather: zm.config.EnableWeather && zoneData.Configuration.WeatherAllowed,
EnablePathfinding: zm.config.EnablePathfinding,
}
// Initialize zone server
if err := zoneServer.Initialize(config); err != nil {
return nil, fmt.Errorf("failed to initialize zone server: %v", err)
}
// Add to manager
zm.zones[zoneID] = zoneServer
zm.zonesByName[zoneData.Configuration.Name] = zoneServer
log.Printf("%s Loaded zone '%s' (ID: %d)", LogPrefixZone, zoneData.Configuration.Name, zoneID)
return zoneServer, nil
}
// UnloadZone unloads a zone by ID
func (zm *ZoneManager) UnloadZone(zoneID int32) error {
zm.mutex.Lock()
defer zm.mutex.Unlock()
zone, exists := zm.zones[zoneID]
if !exists {
return fmt.Errorf("zone %d not found", zoneID)
}
// Check if zone has players
if zone.GetNumPlayers() > 0 {
return fmt.Errorf("cannot unload zone with active players")
}
// Shutdown zone
zone.Shutdown()
// Wait for shutdown to complete
timeout := time.After(time.Second * 30)
ticker := time.NewTicker(time.Millisecond * 100)
defer ticker.Stop()
for {
select {
case <-timeout:
log.Printf("%s Warning: zone %d shutdown timed out", LogPrefixZone, zoneID)
break
case <-ticker.C:
if zone.IsShuttingDown() {
break
}
}
break
}
// Remove from manager
delete(zm.zones, zoneID)
delete(zm.zonesByName, zone.GetZoneName())
log.Printf("%s Unloaded zone '%s' (ID: %d)", LogPrefixZone, zone.GetZoneName(), zoneID)
return nil
}
// CreateInstance creates a new instance zone
func (zm *ZoneManager) CreateInstance(baseZoneID int32, instanceType InstanceType, creatorID uint32) (*ZoneServer, error) {
zm.mutex.Lock()
defer zm.mutex.Unlock()
// Check instance limit
if int32(len(zm.instanceZones)) >= zm.config.MaxInstanceZones {
return nil, fmt.Errorf("maximum instance zones reached (%d)", zm.config.MaxInstanceZones)
}
// Load base zone data
zoneDB := NewZoneDatabase(zm.db.DB)
zoneData, err := zoneDB.LoadZoneData(baseZoneID)
if err != nil {
return nil, fmt.Errorf("failed to load base zone data: %v", err)
}
// Generate instance ID
instanceID := zm.generateInstanceID()
// Create instance zone
instanceName := fmt.Sprintf("%s_instance_%d", zoneData.Configuration.Name, instanceID)
zoneServer := NewZoneServer(instanceName)
// Configure instance zone
config := &ZoneServerConfig{
ZoneName: instanceName,
ZoneFile: zoneData.Configuration.File,
ZoneDescription: zoneData.Configuration.Description,
ZoneID: baseZoneID,
InstanceID: instanceID,
InstanceType: instanceType,
DatabasePath: zm.config.DatabasePath,
MaxPlayers: zm.getInstanceMaxPlayers(instanceType),
MinLevel: zoneData.Configuration.MinLevel,
MaxLevel: zoneData.Configuration.MaxLevel,
SafeX: zoneData.Configuration.SafeX,
SafeY: zoneData.Configuration.SafeY,
SafeZ: zoneData.Configuration.SafeZ,
SafeHeading: zoneData.Configuration.SafeHeading,
LoadMaps: true,
EnableWeather: zm.config.EnableWeather && zoneData.Configuration.WeatherAllowed,
EnablePathfinding: zm.config.EnablePathfinding,
}
// Initialize instance zone
if err := zoneServer.Initialize(config); err != nil {
return nil, fmt.Errorf("failed to initialize instance zone: %v", err)
}
// Add to manager
zm.instanceZones[instanceID] = zoneServer
log.Printf("%s Created instance zone '%s' (ID: %d, Type: %s)",
LogPrefixZone, instanceName, instanceID, instanceType.String())
return zoneServer, nil
}
// DestroyInstance destroys an instance zone
func (zm *ZoneManager) DestroyInstance(instanceID int32) error {
zm.mutex.Lock()
defer zm.mutex.Unlock()
zone, exists := zm.instanceZones[instanceID]
if !exists {
return fmt.Errorf("instance %d not found", instanceID)
}
// Shutdown instance
zone.Shutdown()
// Remove from manager
delete(zm.instanceZones, instanceID)
log.Printf("%s Destroyed instance zone '%s' (ID: %d)",
LogPrefixZone, zone.GetZoneName(), instanceID)
return nil
}
// GetZone retrieves a zone by ID
func (zm *ZoneManager) GetZone(zoneID int32) *ZoneServer {
zm.mutex.RLock()
defer zm.mutex.RUnlock()
return zm.zones[zoneID]
}
// GetZoneByName retrieves a zone by name
func (zm *ZoneManager) GetZoneByName(name string) *ZoneServer {
zm.mutex.RLock()
defer zm.mutex.RUnlock()
return zm.zonesByName[name]
}
// GetInstance retrieves an instance zone by ID
func (zm *ZoneManager) GetInstance(instanceID int32) *ZoneServer {
zm.mutex.RLock()
defer zm.mutex.RUnlock()
return zm.instanceZones[instanceID]
}
// GetAllZones returns a list of all active zones
func (zm *ZoneManager) GetAllZones() []*ZoneServer {
zm.mutex.RLock()
defer zm.mutex.RUnlock()
zones := make([]*ZoneServer, 0, len(zm.zones))
for _, zone := range zm.zones {
zones = append(zones, zone)
}
return zones
}
// GetAllInstances returns a list of all active instances
func (zm *ZoneManager) GetAllInstances() []*ZoneServer {
zm.mutex.RLock()
defer zm.mutex.RUnlock()
instances := make([]*ZoneServer, 0, len(zm.instanceZones))
for _, instance := range zm.instanceZones {
instances = append(instances, instance)
}
return instances
}
// GetZoneCount returns the number of active zones
func (zm *ZoneManager) GetZoneCount() int {
zm.mutex.RLock()
defer zm.mutex.RUnlock()
return len(zm.zones)
}
// GetInstanceCount returns the number of active instances
func (zm *ZoneManager) GetInstanceCount() int {
zm.mutex.RLock()
defer zm.mutex.RUnlock()
return len(zm.instanceZones)
}
// GetTotalPlayerCount returns the total number of players across all zones
func (zm *ZoneManager) GetTotalPlayerCount() int32 {
zm.mutex.RLock()
defer zm.mutex.RUnlock()
var total int32
for _, zone := range zm.zones {
total += zone.GetNumPlayers()
}
for _, instance := range zm.instanceZones {
total += instance.GetNumPlayers()
}
return total
}
// BroadcastMessage sends a message to all players in all zones
func (zm *ZoneManager) BroadcastMessage(message string) {
zm.mutex.RLock()
zones := make([]*ZoneServer, 0, len(zm.zones)+len(zm.instanceZones))
for _, zone := range zm.zones {
zones = append(zones, zone)
}
for _, instance := range zm.instanceZones {
zones = append(zones, instance)
}
zm.mutex.RUnlock()
for _, zone := range zones {
// TODO: Implement broadcast message to zone
_ = zone
_ = message
}
}
// GetStatistics returns zone manager statistics
func (zm *ZoneManager) GetStatistics() *ZoneManagerStatistics {
zm.mutex.RLock()
defer zm.mutex.RUnlock()
stats := &ZoneManagerStatistics{
TotalZones: int32(len(zm.zones)),
TotalInstances: int32(len(zm.instanceZones)),
TotalPlayers: zm.GetTotalPlayerCount(),
MaxZones: zm.config.MaxZones,
MaxInstances: zm.config.MaxInstanceZones,
ZoneDetails: make([]*ZoneStatistics, 0),
InstanceDetails: make([]*ZoneStatistics, 0),
}
for _, zone := range zm.zones {
zoneStats := &ZoneStatistics{
ZoneID: zone.GetZoneID(),
ZoneName: zone.GetZoneName(),
PlayerCount: zone.GetNumPlayers(),
MaxPlayers: zone.GetMaxPlayers(),
IsLocked: zone.IsLocked(),
WatchdogTime: zone.GetWatchdogTime(),
}
stats.ZoneDetails = append(stats.ZoneDetails, zoneStats)
}
for _, instance := range zm.instanceZones {
instanceStats := &ZoneStatistics{
ZoneID: instance.GetZoneID(),
InstanceID: instance.GetInstanceID(),
ZoneName: instance.GetZoneName(),
PlayerCount: instance.GetNumPlayers(),
MaxPlayers: instance.GetMaxPlayers(),
IsLocked: instance.IsLocked(),
WatchdogTime: instance.GetWatchdogTime(),
}
stats.InstanceDetails = append(stats.InstanceDetails, instanceStats)
}
return stats
}
// Private methods
func (zm *ZoneManager) processLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("%s Zone manager process loop panic: %v", LogPrefixZone, r)
}
}()
for {
select {
case <-zm.shutdownSignal:
return
case <-zm.processTimer.C:
zm.processAllZones()
}
}
}
func (zm *ZoneManager) cleanupLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("%s Zone manager cleanup loop panic: %v", LogPrefixZone, r)
}
}()
for {
select {
case <-zm.shutdownSignal:
return
case <-zm.cleanupTimer.C:
zm.cleanupInactiveInstances()
}
}
}
func (zm *ZoneManager) processAllZones() {
// Get all zones to process
zm.mutex.RLock()
zones := make([]*ZoneServer, 0, len(zm.zones)+len(zm.instanceZones))
for _, zone := range zm.zones {
zones = append(zones, zone)
}
for _, instance := range zm.instanceZones {
zones = append(zones, instance)
}
zm.mutex.RUnlock()
// Process each zone
for _, zone := range zones {
if zone.IsShuttingDown() {
continue
}
// Process main zone logic
if err := zone.Process(); err != nil {
log.Printf("%s Error processing zone '%s': %v", LogPrefixZone, zone.GetZoneName(), err)
}
// Process spawn logic
if err := zone.SpawnProcess(); err != nil {
log.Printf("%s Error processing spawns in zone '%s': %v", LogPrefixZone, zone.GetZoneName(), err)
}
}
}
func (zm *ZoneManager) cleanupInactiveInstances() {
zm.mutex.Lock()
defer zm.mutex.Unlock()
instancesToRemove := make([]int32, 0)
for instanceID, instance := range zm.instanceZones {
// Remove instances with no players that have been inactive
if instance.GetNumPlayers() == 0 {
// Check how long instance has been empty
// TODO: Track instance creation/last activity time
instancesToRemove = append(instancesToRemove, instanceID)
}
}
// Remove inactive instances
for _, instanceID := range instancesToRemove {
if instance, exists := zm.instanceZones[instanceID]; exists {
instance.Shutdown()
delete(zm.instanceZones, instanceID)
log.Printf("%s Cleaned up inactive instance %d", LogPrefixZone, instanceID)
}
}
}
func (zm *ZoneManager) generateInstanceID() int32 {
// Simple instance ID generation - start from 1000 and increment
// In production, this should be more sophisticated
instanceID := int32(1000)
for {
if _, exists := zm.instanceZones[instanceID]; !exists {
return instanceID
}
instanceID++
if instanceID > 999999 { // Wrap around to prevent overflow
instanceID = 1000
}
}
}
func (zm *ZoneManager) getInstanceMaxPlayers(instanceType InstanceType) int32 {
switch instanceType {
case InstanceTypeGroupLockout, InstanceTypeGroupPersist:
return 6
case InstanceTypeRaidLockout, InstanceTypeRaidPersist:
return 24
case InstanceTypeSoloLockout, InstanceTypeSoloPersist:
return 1
case InstanceTypeTradeskill, InstanceTypePublic:
return 50
case InstanceTypePersonalHouse:
return 10
case InstanceTypeGuildHouse:
return 50
case InstanceTypeQuest:
return 6
default:
return 6
}
}
// ZoneManagerStatistics holds statistics about the zone manager
type ZoneManagerStatistics struct {
TotalZones int32 `json:"total_zones"`
TotalInstances int32 `json:"total_instances"`
TotalPlayers int32 `json:"total_players"`
MaxZones int32 `json:"max_zones"`
MaxInstances int32 `json:"max_instances"`
ZoneDetails []*ZoneStatistics `json:"zone_details"`
InstanceDetails []*ZoneStatistics `json:"instance_details"`
}
// ZoneStatistics holds statistics about a specific zone
type ZoneStatistics struct {
ZoneID int32 `json:"zone_id"`
InstanceID int32 `json:"instance_id,omitempty"`
ZoneName string `json:"zone_name"`
PlayerCount int32 `json:"player_count"`
MaxPlayers int32 `json:"max_players"`
IsLocked bool `json:"is_locked"`
WatchdogTime int32 `json:"watchdog_time"`
}

View File

@ -0,0 +1,844 @@
package zone
import (
"fmt"
"log"
"time"
"eq2emu/internal/common"
"eq2emu/internal/spawn"
)
// ZoneServerConfig holds configuration for creating a zone server
type ZoneServerConfig struct {
ZoneName string
ZoneFile string
ZoneSkyFile string
ZoneDescription string
ZoneID int32
InstanceID int32
InstanceType InstanceType
DatabasePath string
MaxPlayers int32
MinLevel int16
MaxLevel int16
SafeX float32
SafeY float32
SafeZ float32
SafeHeading float32
LoadMaps bool
EnableWeather bool
EnablePathfinding bool
}
// Initialize initializes the zone server with all required systems
func (zs *ZoneServer) Initialize(config *ZoneServerConfig) error {
zs.masterZoneLock.Lock()
defer zs.masterZoneLock.Unlock()
if zs.isInitialized.Load() {
return fmt.Errorf("zone server already initialized")
}
log.Printf("%s Initializing zone server '%s' (ID: %d)", LogPrefixZone, config.ZoneName, config.ZoneID)
// Set basic configuration
zs.zoneName = config.ZoneName
zs.zoneFile = config.ZoneFile
zs.zoneSkyFile = config.ZoneSkyFile
zs.zoneDescription = config.ZoneDescription
zs.zoneID = config.ZoneID
zs.instanceID = config.InstanceID
zs.instanceType = config.InstanceType
zs.isInstance = config.InstanceType != InstanceTypeNone
// Set zone limits
zs.minimumLevel = config.MinLevel
zs.maximumLevel = config.MaxLevel
// Set safe coordinates
zs.safeX = config.SafeX
zs.safeY = config.SafeY
zs.safeZ = config.SafeZ
zs.safeHeading = config.SafeHeading
// Initialize timers
if err := zs.initializeTimers(); err != nil {
return fmt.Errorf("failed to initialize timers: %v", err)
}
// Load zone data from database
if err := zs.loadZoneData(); err != nil {
return fmt.Errorf("failed to load zone data: %v", err)
}
// Initialize pathfinding if enabled
if config.EnablePathfinding {
if err := zs.initializePathfinding(); err != nil {
log.Printf("%s Warning: failed to initialize pathfinding: %v", LogPrefixZone, err)
// Don't fail initialization, just log warning
}
}
// Load maps if enabled
if config.LoadMaps {
if err := zs.loadZoneMaps(); err != nil {
log.Printf("%s Warning: failed to load zone maps: %v", LogPrefixZone, err)
// Don't fail initialization, just log warning
}
}
// Initialize weather if enabled
if config.EnableWeather {
zs.initializeWeather()
}
// Initialize movement manager
zs.movementMgr = NewMobMovementManager(zs)
// Start processing threads
zs.startProcessingThreads()
zs.loadingData = false
zs.isInitialized.Store(true)
log.Printf("%s Zone server '%s' initialized successfully", LogPrefixZone, zs.zoneName)
return nil
}
// Process performs the main zone processing loop
func (zs *ZoneServer) Process() error {
if zs.zoneShuttingDown.Load() {
return fmt.Errorf("zone is shutting down")
}
if !zs.isInitialized.Load() {
return fmt.Errorf("zone not initialized")
}
// Update watchdog timestamp
zs.watchdogTimestamp = int32(time.Now().Unix())
// Process clients
zs.processClients()
// Process spawns
zs.processSpawns()
// Process timers
zs.processTimers()
// Process movement
if zs.movementMgr != nil {
zs.movementMgr.Process()
}
// Process spell effects
if zs.spellProcess != nil {
zs.spellProcess.ProcessSpellEffects()
}
// Process pending spawn changes
zs.processSpawnChanges()
// Process proximity checks
zs.processProximityChecks()
// Process weather updates
zs.processWeather()
// Clean up expired data
zs.cleanupExpiredData()
return nil
}
// SpawnProcess performs spawn-specific processing
func (zs *ZoneServer) SpawnProcess() error {
if zs.zoneShuttingDown.Load() {
return fmt.Errorf("zone is shutting down")
}
// Process spawn locations for respawns
zs.processSpawnLocations()
// Process spawn movement
zs.processSpawnMovement()
// Process NPC AI
zs.processNPCAI()
// Process combat
zs.processCombat()
// Check for dead spawns to remove
zs.checkDeadSpawnRemoval()
// Process spawn script timers
zs.processSpawnScriptTimers()
return nil
}
// AddClient adds a client to the zone
func (zs *ZoneServer) AddClient(client Client) error {
if zs.zoneShuttingDown.Load() {
return fmt.Errorf("zone is shutting down")
}
zs.clientListLock.Lock()
defer zs.clientListLock.Unlock()
// Check zone capacity
if len(zs.clients) >= int(zs.GetMaxPlayers()) {
return fmt.Errorf("zone is full")
}
// Check client requirements
if !zs.canClientEnter(client) {
return fmt.Errorf("client does not meet zone requirements")
}
// Add client to list
zs.clients = append(zs.clients, client)
zs.numPlayers = int32(len(zs.clients))
zs.lifetimeClientCount++
log.Printf("%s Client %s entered zone '%s' (%d/%d players)",
LogPrefixZone, client.GetPlayerName(), zs.zoneName, zs.numPlayers, zs.GetMaxPlayers())
// Initialize client in zone
go zs.initializeClientInZone(client)
return nil
}
// RemoveClient removes a client from the zone
func (zs *ZoneServer) RemoveClient(client Client) {
zs.clientListLock.Lock()
defer zs.clientListLock.Unlock()
// Find and remove client
for i, c := range zs.clients {
if c.GetID() == client.GetID() {
// Remove from slice
zs.clients = append(zs.clients[:i], zs.clients[i+1:]...)
zs.numPlayers = int32(len(zs.clients))
log.Printf("%s Client %s left zone '%s' (%d/%d players)",
LogPrefixZone, client.GetPlayerName(), zs.zoneName, zs.numPlayers, zs.GetMaxPlayers())
// Clean up client-specific data
zs.cleanupClientData(client)
break
}
}
}
// AddSpawn adds a spawn to the zone
func (zs *ZoneServer) AddSpawn(spawn *spawn.Spawn) error {
if zs.zoneShuttingDown.Load() {
return fmt.Errorf("zone is shutting down")
}
zs.spawnListLock.Lock()
defer zs.spawnListLock.Unlock()
// Add to spawn list
zs.spawnList[spawn.GetID()] = spawn
// Add to appropriate grid
zs.addSpawnToGrid(spawn)
// Mark as changed for client updates
zs.markSpawnChanged(spawn.GetID())
log.Printf("%s Added spawn '%s' (ID: %d) to zone '%s'",
LogPrefixZone, spawn.GetName(), spawn.GetID(), zs.zoneName)
return nil
}
// RemoveSpawn removes a spawn from the zone
func (zs *ZoneServer) RemoveSpawn(spawnID int32, deleteSpawn bool) error {
zs.spawnListLock.Lock()
defer zs.spawnListLock.Unlock()
spawn, exists := zs.spawnList[spawnID]
if !exists {
return fmt.Errorf("spawn %d not found", spawnID)
}
// Remove from grids
zs.removeSpawnFromGrid(spawn)
// Clean up spawn data
zs.cleanupSpawnData(spawn)
// Remove from spawn list
delete(zs.spawnList, spawnID)
// Mark for client removal
zs.markSpawnForRemoval(spawnID)
log.Printf("%s Removed spawn '%s' (ID: %d) from zone '%s'",
LogPrefixZone, spawn.GetName(), spawn.GetID(), zs.zoneName)
return nil
}
// GetSpawn retrieves a spawn by ID
func (zs *ZoneServer) GetSpawn(spawnID int32) *spawn.Spawn {
zs.spawnListLock.RLock()
defer zs.spawnListLock.RUnlock()
return zs.spawnList[spawnID]
}
// GetSpawnsByRange retrieves all spawns within range of a position
func (zs *ZoneServer) GetSpawnsByRange(x, y, z, maxRange float32) []*spawn.Spawn {
zs.spawnListLock.RLock()
defer zs.spawnListLock.RUnlock()
var nearbySpawns []*spawn.Spawn
maxRangeSquared := maxRange * maxRange
for _, spawn := range zs.spawnList {
spawnX, spawnY, spawnZ, _ := spawn.GetPosition()
distSquared := Distance3DSquared(x, y, z, spawnX, spawnY, spawnZ)
if distSquared <= maxRangeSquared {
nearbySpawns = append(nearbySpawns, spawn)
}
}
return nearbySpawns
}
// GetGridsByLocation retrieves grid IDs that contain the specified location
func (zs *ZoneServer) GetGridsByLocation(x, y, z, distance float32) []int32 {
// Calculate grid boundaries
minGridX := int32((x - distance) / DefaultGridSize)
maxGridX := int32((x + distance) / DefaultGridSize)
minGridY := int32((y - distance) / DefaultGridSize)
maxGridY := int32((y + distance) / DefaultGridSize)
var gridIDs []int32
for gridX := minGridX; gridX <= maxGridX; gridX++ {
for gridY := minGridY; gridY <= maxGridY; gridY++ {
gridID := gridX*1000 + gridY // Simple grid ID calculation
if gridID >= 0 && gridID <= MaxGridID {
gridIDs = append(gridIDs, gridID)
}
}
}
return gridIDs
}
// SendZoneSpawns sends all visible spawns to a client
func (zs *ZoneServer) SendZoneSpawns(client Client) error {
playerX, playerY, playerZ, _, _ := client.GetPosition()
// Get spawns in range
spawns := zs.GetSpawnsByRange(playerX, playerY, playerZ, SendSpawnDistance)
log.Printf("%s Sending %d spawns to client %s", LogPrefixZone, len(spawns), client.GetPlayerName())
// Send each spawn
for _, spawn := range spawns {
if client.CanSeeSpawn(spawn) {
zs.sendSpawnToClient(client, spawn)
}
}
return nil
}
// ProcessWeather handles zone-wide weather changes
func (zs *ZoneServer) ProcessWeather() {
if !zs.weatherEnabled || !zs.weatherAllowed {
return
}
currentTime := int32(time.Now().Unix())
// Check if it's time for a weather change
if currentTime-zs.weatherLastChangedTime < zs.weatherFrequency {
return
}
// Roll for weather change
if zs.weatherChangeChance > 0 {
// Simple random roll (0-100)
roll := currentTime % 100
if int8(roll) > zs.weatherChangeChance {
return
}
}
// Calculate weather change
var change float32
switch zs.weatherType {
case WeatherTypeNormal:
change = zs.weatherChangeAmount
case WeatherTypeDynamic:
// Dynamic weather with random offset
change = zs.weatherChangeAmount + (float32(currentTime%100)/100.0-0.5)*zs.weatherDynamicOffset
case WeatherTypeRandom:
// Completely random change
change = (float32(currentTime%100)/100.0 - 0.5) * 2.0 * zs.weatherMaxSeverity
case WeatherTypeChaotic:
// Chaotic weather with large swings
change = (float32(currentTime%100)/100.0 - 0.5) * 4.0 * zs.weatherMaxSeverity
}
// Apply pattern
switch zs.weatherPattern {
case WeatherPatternDecreasing:
change = -abs(change)
case WeatherPatternIncreasing:
change = abs(change)
// WeatherPatternRandom uses calculated change as-is
}
// Update weather severity
newSeverity := zs.weatherCurrentSeverity + change
// Clamp to bounds
if newSeverity < zs.weatherMinSeverity {
newSeverity = zs.weatherMinSeverity
}
if newSeverity > zs.weatherMaxSeverity {
newSeverity = zs.weatherMaxSeverity
}
// Check if severity actually changed
if newSeverity != zs.weatherCurrentSeverity {
zs.weatherCurrentSeverity = newSeverity
zs.rain = newSeverity
// Send weather update to all clients
zs.sendWeatherUpdate()
log.Printf("%s Weather changed to %.2f in zone '%s'", LogPrefixWeather, newSeverity, zs.zoneName)
}
zs.weatherLastChangedTime = currentTime
}
// SetRain sets the rain level in the zone
func (zs *ZoneServer) SetRain(val float32) {
zs.rain = val
zs.weatherCurrentSeverity = val
zs.sendWeatherUpdate()
}
// GetZoneID returns the zone ID
func (zs *ZoneServer) GetZoneID() int32 {
return zs.zoneID
}
// GetZoneName returns the zone name
func (zs *ZoneServer) GetZoneName() string {
return zs.zoneName
}
// GetInstanceID returns the instance ID
func (zs *ZoneServer) GetInstanceID() int32 {
return zs.instanceID
}
// GetInstanceType returns the instance type
func (zs *ZoneServer) GetInstanceType() InstanceType {
return zs.instanceType
}
// IsInstanceZone returns whether this is an instance zone
func (zs *ZoneServer) IsInstanceZone() bool {
return zs.isInstance
}
// GetNumPlayers returns the current number of players
func (zs *ZoneServer) GetNumPlayers() int32 {
return zs.numPlayers
}
// GetMaxPlayers returns the maximum number of players allowed
func (zs *ZoneServer) GetMaxPlayers() int32 {
if zs.isInstance {
// Instance zones have different limits based on type
switch zs.instanceType {
case InstanceTypeGroupLockout, InstanceTypeGroupPersist:
return 6
case InstanceTypeRaidLockout, InstanceTypeRaidPersist:
return 24
case InstanceTypeSoloLockout, InstanceTypeSoloPersist:
return 1
case InstanceTypeTradeskill, InstanceTypePublic:
return DefaultMaxPlayers
case InstanceTypePersonalHouse:
return 10
case InstanceTypeGuildHouse:
return 50
case InstanceTypeQuest:
return 6
}
}
return DefaultMaxPlayers
}
// GetSafePosition returns the safe position coordinates
func (zs *ZoneServer) GetSafePosition() (x, y, z, heading float32) {
return zs.safeX, zs.safeY, zs.safeZ, zs.safeHeading
}
// SetSafePosition sets the safe position coordinates
func (zs *ZoneServer) SetSafePosition(x, y, z, heading float32) {
zs.safeX = x
zs.safeY = y
zs.safeZ = z
zs.safeHeading = heading
}
// IsLocked returns whether the zone is locked
func (zs *ZoneServer) IsLocked() bool {
return zs.locked
}
// SetLocked sets the zone lock state
func (zs *ZoneServer) SetLocked(locked bool) {
zs.locked = locked
if locked {
log.Printf("%s Zone '%s' has been locked", LogPrefixZone, zs.zoneName)
} else {
log.Printf("%s Zone '%s' has been unlocked", LogPrefixZone, zs.zoneName)
}
}
// GetWatchdogTime returns the last watchdog timestamp
func (zs *ZoneServer) GetWatchdogTime() int32 {
return zs.watchdogTimestamp
}
// Private helper methods
func (zs *ZoneServer) initializeTimers() error {
zs.aggroTimer = common.NewTimer(AggroCheckInterval)
zs.charsheetChanges = common.NewTimer(CharsheetUpdateInterval)
zs.clientSave = common.NewTimer(ClientSaveInterval)
zs.locationProxTimer = common.NewTimer(LocationProximityInterval)
zs.movementTimer = common.NewTimer(MovementUpdateInterval)
zs.regenTimer = common.NewTimer(DefaultTimerInterval)
zs.respawnTimer = common.NewTimer(RespawnCheckInterval)
zs.shutdownTimer = common.NewTimer(0) // Disabled by default
zs.spawnRangeTimer = common.NewTimer(SpawnRangeUpdateInterval)
zs.spawnUpdateTimer = common.NewTimer(DefaultTimerInterval)
zs.syncGameTimer = common.NewTimer(DefaultTimerInterval)
zs.trackingTimer = common.NewTimer(TrackingUpdateInterval)
zs.weatherTimer = common.NewTimer(WeatherUpdateInterval)
zs.widgetTimer = common.NewTimer(WidgetUpdateInterval)
return nil
}
func (zs *ZoneServer) loadZoneData() error {
// TODO: Load zone data from database
// This would include spawn locations, NPCs, objects, etc.
log.Printf("%s Loading zone data for '%s'", LogPrefixZone, zs.zoneName)
return nil
}
func (zs *ZoneServer) initializePathfinding() error {
// TODO: Initialize pathfinding system
log.Printf("%s Initializing pathfinding for zone '%s'", LogPrefixPathfind, zs.zoneName)
return nil
}
func (zs *ZoneServer) loadZoneMaps() error {
// TODO: Load zone maps and collision data
log.Printf("%s Loading maps for zone '%s'", LogPrefixMap, zs.zoneName)
return nil
}
func (zs *ZoneServer) initializeWeather() {
zs.weatherEnabled = true
zs.weatherType = WeatherTypeNormal
zs.weatherFrequency = DefaultWeatherFrequency
zs.weatherMinSeverity = DefaultWeatherMinSeverity
zs.weatherMaxSeverity = DefaultWeatherMaxSeverity
zs.weatherChangeAmount = DefaultWeatherChangeAmount
zs.weatherDynamicOffset = DefaultWeatherDynamicOffset
zs.weatherChangeChance = DefaultWeatherChangeChance
zs.weatherPattern = WeatherPatternRandom
zs.weatherCurrentSeverity = 0.0
zs.weatherLastChangedTime = int32(time.Now().Unix())
log.Printf("%s Weather system initialized for zone '%s'", LogPrefixWeather, zs.zoneName)
}
func (zs *ZoneServer) startProcessingThreads() {
zs.spawnThreadActive = true
zs.combatThreadActive = true
zs.clientThreadActive = true
log.Printf("%s Started processing threads for zone '%s'", LogPrefixZone, zs.zoneName)
}
func (zs *ZoneServer) canClientEnter(client Client) bool {
// Check level requirements
player := client.GetPlayer()
if player != nil {
level := player.GetLevel()
if zs.minimumLevel > 0 && level < zs.minimumLevel {
return false
}
if zs.maximumLevel > 0 && level > zs.maximumLevel {
return false
}
}
// Check client version
version := client.GetClientVersion()
if zs.minimumVersion > 0 && int16(version) < zs.minimumVersion {
return false
}
// Check if zone is locked
if zs.locked {
return false
}
return true
}
func (zs *ZoneServer) initializeClientInZone(client Client) {
// Send zone information
zs.sendZoneInfo(client)
// Send all visible spawns
zs.SendZoneSpawns(client)
// Send weather update
if zs.weatherEnabled {
zs.sendWeatherUpdateToClient(client)
}
// Send time update
zs.sendTimeUpdateToClient(client)
}
func (zs *ZoneServer) processClients() {
// Process each client
zs.clientListLock.RLock()
clients := make([]Client, len(zs.clients))
copy(clients, zs.clients)
zs.clientListLock.RUnlock()
for _, client := range clients {
if client.IsLoadingZone() {
continue
}
// Update spawn visibility
zs.updateClientSpawnVisibility(client)
}
}
func (zs *ZoneServer) processSpawns() {
// Process spawn updates and changes
zs.spawnListLock.RLock()
spawns := make([]*spawn.Spawn, 0, len(zs.spawnList))
for _, spawn := range zs.spawnList {
spawns = append(spawns, spawn)
}
zs.spawnListLock.RUnlock()
for _, spawn := range spawns {
// Process spawn logic here
_ = spawn // Placeholder
}
}
func (zs *ZoneServer) processTimers() {
// Check and process all timers
if zs.aggroTimer.Check() {
zs.processAggroChecks()
}
if zs.respawnTimer.Check() {
zs.processRespawns()
}
if zs.widgetTimer.Check() {
zs.processWidgets()
}
// Add other timer checks...
}
func (zs *ZoneServer) processSpawnChanges() {
zs.changedSpawnsLock.Lock()
defer zs.changedSpawnsLock.Unlock()
if len(zs.changedSpawns) == 0 {
return
}
// Send changes to all clients
zs.clientListLock.RLock()
clients := make([]Client, len(zs.clients))
copy(clients, zs.clients)
zs.clientListLock.RUnlock()
for spawnID := range zs.changedSpawns {
spawn := zs.GetSpawn(spawnID)
if spawn != nil {
for _, client := range clients {
if client.CanSeeSpawn(spawn) {
zs.sendSpawnUpdateToClient(client, spawn)
}
}
}
}
// Clear changed spawns
zs.changedSpawns = make(map[int32]bool)
}
func (zs *ZoneServer) processProximityChecks() {
// Process player and location proximity
if zs.locationProxTimer.Check() {
zs.checkLocationProximity()
zs.checkPlayerProximity()
}
}
func (zs *ZoneServer) processWeather() {
if zs.weatherTimer.Check() {
zs.ProcessWeather()
}
}
func (zs *ZoneServer) cleanupExpiredData() {
// Clean up dead spawns, expired timers, etc.
zs.cleanupDeadSpawns()
zs.cleanupExpiredTimers()
}
// Helper functions for various processing tasks
func (zs *ZoneServer) processSpawnLocations() {
// TODO: Process spawn location respawns
}
func (zs *ZoneServer) processSpawnMovement() {
// TODO: Process NPC movement
}
func (zs *ZoneServer) processNPCAI() {
// TODO: Process NPC AI
}
func (zs *ZoneServer) processCombat() {
// TODO: Process combat
}
func (zs *ZoneServer) checkDeadSpawnRemoval() {
// TODO: Check for dead spawns to remove
}
func (zs *ZoneServer) processSpawnScriptTimers() {
// TODO: Process spawn script timers
}
func (zs *ZoneServer) processAggroChecks() {
// TODO: Process aggro checks
}
func (zs *ZoneServer) processRespawns() {
// TODO: Process respawns
}
func (zs *ZoneServer) processWidgets() {
// TODO: Process widget timers
}
func (zs *ZoneServer) checkLocationProximity() {
// TODO: Check location proximity
}
func (zs *ZoneServer) checkPlayerProximity() {
// TODO: Check player proximity
}
func (zs *ZoneServer) cleanupDeadSpawns() {
// TODO: Clean up dead spawns
}
func (zs *ZoneServer) cleanupExpiredTimers() {
// TODO: Clean up expired timers
}
func (zs *ZoneServer) cleanupClientData(client Client) {
// TODO: Clean up client-specific data
}
func (zs *ZoneServer) cleanupSpawnData(spawn *spawn.Spawn) {
// TODO: Clean up spawn-specific data
}
func (zs *ZoneServer) addSpawnToGrid(spawn *spawn.Spawn) {
// TODO: Add spawn to grid system
}
func (zs *ZoneServer) removeSpawnFromGrid(spawn *spawn.Spawn) {
// TODO: Remove spawn from grid system
}
func (zs *ZoneServer) markSpawnChanged(spawnID int32) {
zs.changedSpawnsLock.Lock()
defer zs.changedSpawnsLock.Unlock()
zs.changedSpawns[spawnID] = true
}
func (zs *ZoneServer) markSpawnForRemoval(spawnID int32) {
zs.pendingSpawnRemoveLock.Lock()
defer zs.pendingSpawnRemoveLock.Unlock()
zs.pendingSpawnRemove[spawnID] = true
}
func (zs *ZoneServer) sendSpawnToClient(client Client, spawn *spawn.Spawn) {
// TODO: Send spawn packet to client
}
func (zs *ZoneServer) sendSpawnUpdateToClient(client Client, spawn *spawn.Spawn) {
// TODO: Send spawn update packet to client
}
func (zs *ZoneServer) sendZoneInfo(client Client) {
// TODO: Send zone info packet to client
}
func (zs *ZoneServer) sendWeatherUpdate() {
// TODO: Send weather update to all clients
}
func (zs *ZoneServer) sendWeatherUpdateToClient(client Client) {
// TODO: Send weather update to specific client
}
func (zs *ZoneServer) sendTimeUpdateToClient(client Client) {
// TODO: Send time update to client
}
func (zs *ZoneServer) updateClientSpawnVisibility(client Client) {
// TODO: Update spawn visibility for client
}
func abs(x float32) float32 {
if x < 0 {
return -x
}
return x
}

422
internal/zone/zone_test.go Normal file
View File

@ -0,0 +1,422 @@
package zone
import (
"database/sql"
"testing"
"time"
"eq2emu/internal/database"
_ "zombiezen.com/go/sqlite"
)
// TestZoneCreation tests basic zone server creation
func TestZoneCreation(t *testing.T) {
zoneName := "test_zone"
zoneServer := NewZoneServer(zoneName)
if zoneServer == nil {
t.Fatal("Expected non-nil zone server")
}
if zoneServer.GetZoneName() != zoneName {
t.Errorf("Expected zone name '%s', got '%s'", zoneName, zoneServer.GetZoneName())
}
if zoneServer.IsInitialized() {
t.Error("Expected zone to not be initialized")
}
if zoneServer.IsShuttingDown() {
t.Error("Expected zone to not be shutting down")
}
}
// TestZoneInitialization tests zone server initialization
func TestZoneInitialization(t *testing.T) {
zoneServer := NewZoneServer("test_zone")
config := &ZoneServerConfig{
ZoneName: "test_zone",
ZoneFile: "test.zone",
ZoneDescription: "Test Zone",
ZoneID: 1,
InstanceID: 0,
InstanceType: InstanceTypeNone,
MaxPlayers: 100,
MinLevel: 1,
MaxLevel: 100,
SafeX: 0.0,
SafeY: 0.0,
SafeZ: 0.0,
SafeHeading: 0.0,
LoadMaps: false, // Don't load maps in tests
EnableWeather: false, // Don't enable weather in tests
EnablePathfinding: false, // Don't enable pathfinding in tests
}
err := zoneServer.Initialize(config)
if err != nil {
t.Fatalf("Failed to initialize zone server: %v", err)
}
if !zoneServer.IsInitialized() {
t.Error("Expected zone to be initialized")
}
if zoneServer.GetZoneID() != 1 {
t.Errorf("Expected zone ID 1, got %d", zoneServer.GetZoneID())
}
if zoneServer.GetInstanceID() != 0 {
t.Errorf("Expected instance ID 0, got %d", zoneServer.GetInstanceID())
}
// Test safe position
x, y, z, heading := zoneServer.GetSafePosition()
if x != 0.0 || y != 0.0 || z != 0.0 || heading != 0.0 {
t.Errorf("Expected safe position (0,0,0,0), got (%.2f,%.2f,%.2f,%.2f)", x, y, z, heading)
}
}
// TestPositionCalculations tests position utility functions
func TestPositionCalculations(t *testing.T) {
// Test 2D distance
distance := Distance2D(0, 0, 3, 4)
if distance != 5.0 {
t.Errorf("Expected 2D distance 5.0, got %.2f", distance)
}
// Test 3D distance
distance3d := Distance3D(0, 0, 0, 3, 4, 12)
if distance3d != 13.0 {
t.Errorf("Expected 3D distance 13.0, got %.2f", distance3d)
}
// Test heading calculation
heading := CalculateHeading(0, 0, 1, 1)
expected := float32(64.0) // 45 degrees in EQ2 heading units (512/8)
if abs(heading-expected) > 1.0 {
t.Errorf("Expected heading %.2f, got %.2f", expected, heading)
}
// Test heading normalization
normalized := NormalizeHeading(600.0)
expected = 88.0 // 600 - 512
if normalized != expected {
t.Errorf("Expected normalized heading %.2f, got %.2f", expected, normalized)
}
}
// TestPositionStructs tests position data structures
func TestPositionStructs(t *testing.T) {
pos1 := NewPosition(10.0, 20.0, 30.0, 128.0)
pos2 := NewPosition(13.0, 24.0, 30.0, 128.0)
// Test distance calculation
distance := pos1.DistanceTo3D(pos2)
expected := float32(5.0) // 3-4-5 triangle
if distance != expected {
t.Errorf("Expected distance %.2f, got %.2f", expected, distance)
}
// Test position copy
posCopy := pos1.Copy()
if !pos1.Equals(posCopy) {
t.Error("Expected copied position to equal original")
}
// Test bounding box
bbox := NewBoundingBox(0, 0, 0, 10, 10, 10)
if !bbox.Contains(5, 5, 5) {
t.Error("Expected bounding box to contain point (5,5,5)")
}
if bbox.Contains(15, 5, 5) {
t.Error("Expected bounding box to not contain point (15,5,5)")
}
}
// TestMovementManager tests the movement management system
func TestMovementManager(t *testing.T) {
// Create a test zone
zoneServer := NewZoneServer("test_zone")
config := &ZoneServerConfig{
ZoneName: "test_zone",
ZoneID: 1,
LoadMaps: false,
EnableWeather: false,
EnablePathfinding: false,
}
err := zoneServer.Initialize(config)
if err != nil {
t.Fatalf("Failed to initialize zone server: %v", err)
}
// Test movement manager creation
movementMgr := NewMobMovementManager(zoneServer)
if movementMgr == nil {
t.Fatal("Expected non-nil movement manager")
}
// Test adding a spawn to movement tracking
spawnID := int32(1001)
movementMgr.AddMovementSpawn(spawnID)
if !movementMgr.IsMoving(spawnID) == false {
// IsMoving should be false initially
}
// Test queueing a movement command
err = movementMgr.MoveTo(spawnID, 10.0, 20.0, 30.0, DefaultRunSpeed)
if err != nil {
t.Errorf("Failed to queue movement command: %v", err)
}
// Test getting movement state
state := movementMgr.GetMovementState(spawnID)
if state == nil {
t.Error("Expected non-nil movement state")
} else if state.SpawnID != spawnID {
t.Errorf("Expected spawn ID %d, got %d", spawnID, state.SpawnID)
}
}
// TestInstanceTypes tests instance type functionality
func TestInstanceTypes(t *testing.T) {
testCases := []struct {
instanceType InstanceType
expected string
}{
{InstanceTypeNone, "None"},
{InstanceTypeGroupLockout, "Group Lockout"},
{InstanceTypeRaidPersist, "Raid Persistent"},
{InstanceTypePersonalHouse, "Personal House"},
}
for _, tc := range testCases {
result := tc.instanceType.String()
if result != tc.expected {
t.Errorf("Expected instance type string '%s', got '%s'", tc.expected, result)
}
}
}
// TestZoneManager tests the zone manager functionality
func TestZoneManager(t *testing.T) {
// Create test database
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
defer db.Close()
// Create test schema
schema := `
CREATE TABLE zones (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
file TEXT,
description TEXT,
safe_x REAL DEFAULT 0,
safe_y REAL DEFAULT 0,
safe_z REAL DEFAULT 0,
safe_heading REAL DEFAULT 0,
underworld REAL DEFAULT -1000,
min_level INTEGER DEFAULT 0,
max_level INTEGER DEFAULT 0,
min_status INTEGER DEFAULT 0,
min_version INTEGER DEFAULT 0,
instance_type INTEGER DEFAULT 0,
max_players INTEGER DEFAULT 100,
default_lockout_time INTEGER DEFAULT 18000,
default_reenter_time INTEGER DEFAULT 3600,
default_reset_time INTEGER DEFAULT 259200,
group_zone_option INTEGER DEFAULT 0,
expansion_flag INTEGER DEFAULT 0,
holiday_flag INTEGER DEFAULT 0,
can_bind INTEGER DEFAULT 1,
can_gate INTEGER DEFAULT 1,
can_evac INTEGER DEFAULT 1,
city_zone INTEGER DEFAULT 0,
always_loaded INTEGER DEFAULT 0,
weather_allowed INTEGER DEFAULT 1
);
INSERT INTO zones (id, name, file, description) VALUES (1, 'test_zone', 'test.zone', 'Test Zone');
`
if _, err := db.Exec(schema); err != nil {
t.Fatalf("Failed to create test schema: %v", err)
}
// Create database wrapper
dbWrapper := &database.Database{DB: db}
// Create zone manager
config := &ZoneManagerConfig{
MaxZones: 10,
MaxInstanceZones: 50,
ProcessInterval: time.Millisecond * 100,
CleanupInterval: time.Second * 1,
EnableWeather: false,
EnablePathfinding: false,
EnableCombat: false,
EnableSpellProcess: false,
}
zoneManager := NewZoneManager(config, dbWrapper)
if zoneManager == nil {
t.Fatal("Expected non-nil zone manager")
}
// Test zone count initially
if zoneManager.GetZoneCount() != 0 {
t.Errorf("Expected 0 zones initially, got %d", zoneManager.GetZoneCount())
}
// Note: Full zone manager testing would require more complex setup
// including proper database schema and mock implementations
}
// TestWeatherSystem tests weather functionality
func TestWeatherSystem(t *testing.T) {
zoneServer := NewZoneServer("test_zone")
// Initialize with weather enabled
config := &ZoneServerConfig{
ZoneName: "test_zone",
ZoneID: 1,
LoadMaps: false,
EnableWeather: true,
EnablePathfinding: false,
}
err := zoneServer.Initialize(config)
if err != nil {
t.Fatalf("Failed to initialize zone server: %v", err)
}
// Test setting rain level
zoneServer.SetRain(0.5)
// Test weather processing (this is mostly internal)
zoneServer.ProcessWeather()
// Weather system would need more sophisticated testing with time control
}
// TestConstants tests various constants are properly defined
func TestConstants(t *testing.T) {
// Test distance constants
if SendSpawnDistance != 250.0 {
t.Errorf("Expected SendSpawnDistance 250.0, got %.2f", SendSpawnDistance)
}
if MaxChaseDistance != 80.0 {
t.Errorf("Expected MaxChaseDistance 80.0, got %.2f", MaxChaseDistance)
}
// Test expansion constants
if ExpansionDOF != 1024 {
t.Errorf("Expected ExpansionDOF 1024, got %d", ExpansionDOF)
}
// Test EQ2 heading constant
if EQ2HeadingMax != 512.0 {
t.Errorf("Expected EQ2HeadingMax 512.0, got %.2f", EQ2HeadingMax)
}
}
// BenchmarkDistanceCalculation benchmarks distance calculations
func BenchmarkDistanceCalculation(b *testing.B) {
x1, y1, z1 := float32(100.0), float32(200.0), float32(300.0)
x2, y2, z2 := float32(150.0), float32(250.0), float32(350.0)
b.ResetTimer()
for i := 0; i < b.N; i++ {
Distance3D(x1, y1, z1, x2, y2, z2)
}
}
// BenchmarkPositionDistance benchmarks position-based distance calculations
func BenchmarkPositionDistance(b *testing.B) {
pos1 := NewPosition(100.0, 200.0, 300.0, 128.0)
pos2 := NewPosition(150.0, 250.0, 350.0, 256.0)
b.ResetTimer()
for i := 0; i < b.N; i++ {
pos1.DistanceTo3D(pos2)
}
}
// BenchmarkHeadingCalculation benchmarks heading calculations
func BenchmarkHeadingCalculation(b *testing.B) {
fromX, fromY := float32(0.0), float32(0.0)
toX, toY := float32(100.0), float32(100.0)
b.ResetTimer()
for i := 0; i < b.N; i++ {
CalculateHeading(fromX, fromY, toX, toY)
}
}
// MockClient implements the Client interface for testing
type MockClient struct {
id uint32
characterID int32
playerName string
position *Position
clientVersion int32
loadingZone bool
inCombat bool
spawnRange float32
languageID int32
}
func NewMockClient(id uint32, name string) *MockClient {
return &MockClient{
id: id,
characterID: int32(id),
playerName: name,
position: NewPosition(0, 0, 0, 0),
clientVersion: DefaultClientVersion,
loadingZone: false,
inCombat: false,
spawnRange: SendSpawnDistance,
languageID: 0,
}
}
func (mc *MockClient) GetID() uint32 { return mc.id }
func (mc *MockClient) GetCharacterID() int32 { return mc.characterID }
func (mc *MockClient) GetPlayerName() string { return mc.playerName }
func (mc *MockClient) GetPlayer() Player { return nil } // TODO: Mock player
func (mc *MockClient) GetClientVersion() int32 { return mc.clientVersion }
func (mc *MockClient) IsLoadingZone() bool { return mc.loadingZone }
func (mc *MockClient) SendPacket(data []byte) error { return nil }
func (mc *MockClient) GetSpawnRange() float32 { return mc.spawnRange }
func (mc *MockClient) IsInCombat() bool { return mc.inCombat }
func (mc *MockClient) GetLanguageID() int32 { return mc.languageID }
func (mc *MockClient) GetLanguageSkill(languageID int32) int32 { return 100 }
func (mc *MockClient) GetPosition() (x, y, z, heading float32, zoneID int32) {
return mc.position.X, mc.position.Y, mc.position.Z, mc.position.Heading, 1
}
func (mc *MockClient) SetPosition(x, y, z, heading float32, zoneID int32) {
mc.position.Set(x, y, z, heading)
}
func (mc *MockClient) CanSeeSpawn(spawn *Spawn) bool {
// Simple visibility check based on distance
spawnX, spawnY, spawnZ, _ := spawn.GetPosition()
distance := Distance3D(mc.position.X, mc.position.Y, mc.position.Z, spawnX, spawnY, spawnZ)
return distance <= mc.spawnRange
}
// Placeholder import fix
type Spawn = interface {
GetID() int32
GetPosition() (x, y, z, heading float32)
}