clean out all readmes and test fiiles

This commit is contained in:
Sky Johnson 2025-08-23 10:40:33 -05:00
parent 3b9388df44
commit ae9e86b526
70 changed files with 0 additions and 32166 deletions

View File

@ -1,110 +0,0 @@
# EQ2Go Login Server
A modern Go implementation of the EverQuest II login server, providing client authentication, character management, and world server coordination.
## Features
- **Client Authentication**: MD5-hashed password authentication with account management
- **Character Management**: Character list, creation, deletion, and play requests
- **World Server Coordination**: Registration and status tracking of world servers
- **Web Administration**: HTTP interface for monitoring and management
- **Database Integration**: SQLite database with automatic table initialization
- **UDP Protocol**: EverQuest II compatible UDP protocol implementation
## Quick Start
### Building
```bash
go build ./cmd/login_server
```
### Running
```bash
# Run with defaults (creates login_config.json if missing)
./login_server
# Run with custom configuration
./login_server -config custom_login.json
# Run with overrides
./login_server -listen-port 6000 -web-port 8082 -db custom.db
```
### Configuration
On first run, a default `login_config.json` will be created:
```json
{
"listen_addr": "0.0.0.0",
"listen_port": 5999,
"max_clients": 1000,
"web_addr": "0.0.0.0",
"web_port": 8081,
"database_path": "login.db",
"server_name": "EQ2Go Login Server",
"log_level": "info",
"world_servers": []
}
```
## Web Interface
Access the web administration interface at `http://localhost:8081` (or configured web_port).
Features:
- Real-time server statistics
- Connected client monitoring
- World server status
- Client management (kick clients)
## Database
The login server uses SQLite by default with the following tables:
- `login_accounts` - User account information
- `characters` - Character data for character selection
- `server_stats` - Server statistics and monitoring data
## Command Line Options
- `-config` - Path to configuration file (default: login_config.json)
- `-listen-addr` - Override listen address
- `-listen-port` - Override listen port
- `-web-port` - Override web interface port
- `-db` - Override database path
- `-log-level` - Override log level (debug, info, warn, error)
- `-name` - Override server name
- `-version` - Show version information
## Architecture
The login server follows the EQ2Go architecture patterns:
- **Server**: Main server instance managing UDP connections and web interface
- **ClientList**: Thread-safe management of connected clients
- **WorldList**: Management of registered world servers
- **Database Integration**: Uses zombiezen SQLite with proper connection pooling
- **UDP Protocol**: Compatible with EverQuest II client expectations
## Development
The login server integrates with the broader EQ2Go ecosystem:
- Uses `internal/udp` for EverQuest II protocol handling
- Uses `internal/database` for data persistence
- Follows Go concurrency patterns with proper synchronization
- Implements comprehensive error handling and logging
## Next Steps
To complete the login server implementation:
1. Add character creation functionality
2. Add character deletion functionality
3. Implement world server communication protocol
4. Add user registration/account creation
5. Add password reset functionality
6. Add account management features

View File

@ -1,20 +0,0 @@
package achievements
import (
"testing"
)
func TestNew(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestNewAchievement(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterList(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}

View File

@ -1,20 +0,0 @@
package achievements
import (
"testing"
)
func BenchmarkAchievementCreation(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}
func BenchmarkMasterListOperations(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}
func BenchmarkConcurrentAccess(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}

View File

@ -1,30 +0,0 @@
package alt_advancement
import (
"testing"
)
func TestNew(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestNewAltAdvancement(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestAltAdvancementOperations(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestAltAdvancementValidation(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestAltAdvancementConcurrency(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}

View File

@ -1,20 +0,0 @@
package alt_advancement
import (
"testing"
)
func BenchmarkAltAdvancementCreation(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}
func BenchmarkMasterListOperations(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}
func BenchmarkConcurrentAccess(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}

View File

@ -1,71 +0,0 @@
package appearances
import (
"testing"
)
func TestNew(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestNewWithData(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestAppearanceGetters(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestAppearanceSetters(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestIsCompatibleWithClient(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestAppearanceClone(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestGetAppearanceTypeName(t *testing.T) {
// This test doesn't require database, so it can run
testCases := []struct {
typeConst int8
expected string
}{
{0, "Unknown"},
{1, "Hair"},
{2, "Face"},
{3, "Wing"},
{4, "Chest"},
{5, "Legs"},
{-1, "Unknown"},
{100, "Unknown"},
}
for _, tt := range testCases {
t.Run("", func(t *testing.T) {
result := GetAppearanceTypeName(tt.typeConst)
if result != tt.expected {
t.Errorf("GetAppearanceTypeName(%d) = %q, want %q", tt.typeConst, result, tt.expected)
}
})
}
}
func TestMasterList(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListConcurrency(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}

View File

@ -1,20 +0,0 @@
package appearances
import (
"testing"
)
func BenchmarkAppearanceCreation(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}
func BenchmarkMasterListOperations(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}
func BenchmarkConcurrentAccess(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}

View File

@ -1,30 +0,0 @@
package chat
import (
"testing"
)
func BenchmarkChannelCreation(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}
func BenchmarkMasterListOperations(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}
func BenchmarkMessageRouting(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}
func BenchmarkConcurrentAccess(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}
func BenchmarkChannelMemory(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}

View File

@ -1,40 +0,0 @@
package chat
import (
"testing"
)
func TestNew(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestChannelOperations(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestChannelMembers(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestChannelMessage(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestChannelPermissions(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestChannelConcurrency(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestChannelBatch(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}

View File

@ -1,50 +0,0 @@
package chat
import (
"testing"
)
func TestNewMasterList(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListOperations(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListConcurrency(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListChannelManagement(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListUserManagement(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListMessageRouting(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListPermissions(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListEdgeCases(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListPerformance(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}

View File

@ -1,372 +0,0 @@
package classes
import (
"testing"
)
func TestGetClassID(t *testing.T) {
tests := []struct {
name string
input string
expected int8
}{
{"Uppercase", "WARRIOR", ClassWarrior},
{"Lowercase", "warrior", ClassWarrior},
{"Mixed case", "WaRrIoR", ClassWarrior},
{"With spaces", " WARRIOR ", ClassWarrior},
{"Invalid", "INVALID_CLASS", -1},
{"Empty", "", -1},
{"Tradeskill", "CARPENTER", ClassCarpenter},
{"Special", "CHANNELER", ClassChanneler},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GetClassID(tt.input)
if result != tt.expected {
t.Errorf("GetClassID(%q) = %d, want %d", tt.input, result, tt.expected)
}
})
}
}
func TestGetClassName(t *testing.T) {
tests := []struct {
classID int8
expected string
}{
{ClassWarrior, "WARRIOR"},
{ClassPriest, "PRIEST"},
{ClassCarpenter, "CARPENTER"},
{ClassChanneler, "CHANNELER"},
{-1, ""},
{100, ""},
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
result := GetClassName(tt.classID)
if result != tt.expected {
t.Errorf("GetClassName(%d) = %q, want %q", tt.classID, result, tt.expected)
}
})
}
}
func TestGetClassNameCase(t *testing.T) {
tests := []struct {
classID int8
expected string
}{
{ClassWarrior, "Warrior"},
{ClassShadowknight, "Shadowknight"},
{ClassTroubador, "Troubador"},
{ClassCarpenter, "Carpenter"},
{-1, ""},
{100, ""},
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
result := GetClassNameCase(tt.classID)
if result != tt.expected {
t.Errorf("GetClassNameCase(%d) = %q, want %q", tt.classID, result, tt.expected)
}
})
}
}
func TestGetBaseClass(t *testing.T) {
tests := []struct {
classID int8
expected int8
}{
{ClassGuardian, ClassFighter},
{ClassBerserker, ClassFighter},
{ClassTemplar, ClassPriest},
{ClassWizard, ClassMage},
{ClassRanger, ClassScout},
{ClassChanneler, ClassPriest},
{ClassCommoner, ClassCommoner},
{ClassFighter, ClassCommoner},
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
result := GetBaseClass(tt.classID)
if result != tt.expected {
t.Errorf("GetBaseClass(%d) = %d, want %d", tt.classID, result, tt.expected)
}
})
}
}
func TestGetSecondaryBaseClass(t *testing.T) {
tests := []struct {
classID int8
expected int8
}{
{ClassGuardian, ClassWarrior},
{ClassBerserker, ClassWarrior},
{ClassMonk, ClassBrawler},
{ClassTemplar, ClassCleric},
{ClassWizard, ClassSorcerer},
{ClassRanger, ClassPredator},
{ClassBeastlord, ClassAnimalist},
{ClassChanneler, ClassShaper},
{ClassWarrior, ClassCommoner}, // No secondary
{ClassFighter, ClassCommoner}, // No secondary
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
result := GetSecondaryBaseClass(tt.classID)
if result != tt.expected {
t.Errorf("GetSecondaryBaseClass(%d) = %d, want %d", tt.classID, result, tt.expected)
}
})
}
}
func TestGetTSBaseClass(t *testing.T) {
tests := []struct {
classID int8
expected int8
}{
{3, 1}, // Guardian (3+42=45 >= ClassArtisan) returns ClassArtisan-44 = 1
{10, 1}, // Paladin (10+42=52 >= ClassArtisan) returns ClassArtisan-44 = 1
{0, 0}, // Commoner (0+42=42 < ClassArtisan) returns 0
{1, 1}, // Fighter (1+42=43 < ClassArtisan) returns 1
{2, 2}, // Warrior (2+42=44 < ClassArtisan) returns 2
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
result := GetTSBaseClass(tt.classID)
if result != tt.expected {
t.Errorf("GetTSBaseClass(%d) = %d, want %d", tt.classID, result, tt.expected)
}
})
}
}
func TestIsValidClassID(t *testing.T) {
tests := []struct {
classID int8
expected bool
}{
{ClassCommoner, true},
{ClassWarrior, true},
{ClassAlchemist, true},
{-1, false},
{100, false},
{58, false},
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
result := IsValidClassID(tt.classID)
if result != tt.expected {
t.Errorf("IsValidClassID(%d) = %v, want %v", tt.classID, result, tt.expected)
}
})
}
}
func TestIsAdventureClass(t *testing.T) {
tests := []struct {
classID int8
expected bool
}{
{ClassCommoner, true},
{ClassWarrior, true},
{ClassChanneler, true},
{ClassArtisan, false},
{ClassCarpenter, false},
{-1, false},
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
result := IsAdventureClass(tt.classID)
if result != tt.expected {
t.Errorf("IsAdventureClass(%d) = %v, want %v", tt.classID, result, tt.expected)
}
})
}
}
func TestIsTradeskillClass(t *testing.T) {
tests := []struct {
classID int8
expected bool
}{
{ClassArtisan, true},
{ClassCarpenter, true},
{ClassAlchemist, true},
{ClassWarrior, false},
{ClassCommoner, false},
{-1, false},
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
result := IsTradeskillClass(tt.classID)
if result != tt.expected {
t.Errorf("IsTradeskillClass(%d) = %v, want %v", tt.classID, result, tt.expected)
}
})
}
}
func TestGetClassType(t *testing.T) {
tests := []struct {
classID int8
expected string
}{
{ClassWarrior, ClassTypeAdventure},
{ClassChanneler, ClassTypeAdventure},
{ClassArtisan, ClassTypeTradeskill},
{ClassCarpenter, ClassTypeTradeskill},
{-1, ClassTypeSpecial},
{100, ClassTypeSpecial},
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
result := GetClassType(tt.classID)
if result != tt.expected {
t.Errorf("GetClassType(%d) = %q, want %q", tt.classID, result, tt.expected)
}
})
}
}
func TestGetAllClasses(t *testing.T) {
classes := GetAllClasses()
// Check we have the right number of classes
if len(classes) != 58 {
t.Errorf("GetAllClasses() returned %d classes, want 58", len(classes))
}
// Check a few specific classes
if name, ok := classes[ClassWarrior]; !ok || name != "Warrior" {
t.Errorf("GetAllClasses()[ClassWarrior] = %q, %v; want 'Warrior', true", name, ok)
}
if name, ok := classes[ClassCarpenter]; !ok || name != "Carpenter" {
t.Errorf("GetAllClasses()[ClassCarpenter] = %q, %v; want 'Carpenter', true", name, ok)
}
}
func TestGetClassInfo(t *testing.T) {
// Test valid class
info := GetClassInfo(ClassGuardian)
if !info["valid"].(bool) {
t.Error("GetClassInfo(ClassGuardian) should be valid")
}
if info["class_id"].(int8) != ClassGuardian {
t.Errorf("GetClassInfo(ClassGuardian).class_id = %v, want %d", info["class_id"], ClassGuardian)
}
if info["name"].(string) != "GUARDIAN" {
t.Errorf("GetClassInfo(ClassGuardian).name = %v, want 'GUARDIAN'", info["name"])
}
if info["display_name"].(string) != "Guardian" {
t.Errorf("GetClassInfo(ClassGuardian).display_name = %v, want 'Guardian'", info["display_name"])
}
if info["base_class"].(int8) != ClassFighter {
t.Errorf("GetClassInfo(ClassGuardian).base_class = %v, want %d", info["base_class"], ClassFighter)
}
if info["secondary_base_class"].(int8) != ClassWarrior {
t.Errorf("GetClassInfo(ClassGuardian).secondary_base_class = %v, want %d", info["secondary_base_class"], ClassWarrior)
}
if !info["is_adventure"].(bool) {
t.Error("GetClassInfo(ClassGuardian).is_adventure should be true")
}
if info["is_tradeskill"].(bool) {
t.Error("GetClassInfo(ClassGuardian).is_tradeskill should be false")
}
// Test invalid class
info = GetClassInfo(-1)
if info["valid"].(bool) {
t.Error("GetClassInfo(-1) should be invalid")
}
}
func TestGetClassHierarchy(t *testing.T) {
tests := []struct {
classID int8
expected []int8
}{
{ClassGuardian, []int8{ClassGuardian, ClassWarrior, ClassFighter, ClassCommoner}},
{ClassWizard, []int8{ClassWizard, ClassSorcerer, ClassMage, ClassCommoner}},
{ClassFighter, []int8{ClassFighter, ClassCommoner}},
{ClassCommoner, []int8{ClassCommoner}},
{-1, nil},
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
result := GetClassHierarchy(tt.classID)
if len(result) != len(tt.expected) {
t.Errorf("GetClassHierarchy(%d) = %v, want %v", tt.classID, result, tt.expected)
return
}
for i, id := range result {
if id != tt.expected[i] {
t.Errorf("GetClassHierarchy(%d)[%d] = %d, want %d", tt.classID, i, id, tt.expected[i])
}
}
})
}
}
func TestIsSameArchetype(t *testing.T) {
tests := []struct {
classID1 int8
classID2 int8
expected bool
}{
{ClassGuardian, ClassBerserker, true}, // Both Fighter archetype
{ClassGuardian, ClassMonk, true}, // Both Fighter archetype
{ClassWizard, ClassWarlock, true}, // Both Mage archetype
{ClassGuardian, ClassWizard, false}, // Different archetypes
{ClassCommoner, ClassCommoner, true}, // Same class
{-1, ClassGuardian, false}, // Invalid class
{ClassGuardian, -1, false}, // Invalid class
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
result := IsSameArchetype(tt.classID1, tt.classID2)
if result != tt.expected {
t.Errorf("IsSameArchetype(%d, %d) = %v, want %v", tt.classID1, tt.classID2, result, tt.expected)
}
})
}
}
func TestGetArchetypeClasses(t *testing.T) {
// Test Fighter archetype
fighterClasses := GetArchetypeClasses(ClassFighter)
expectedFighterCount := 9 // Warrior through Paladin (2-10)
if len(fighterClasses) != expectedFighterCount {
t.Errorf("GetArchetypeClasses(ClassFighter) returned %d classes, want %d", len(fighterClasses), expectedFighterCount)
}
// Verify all returned classes are fighters
for _, classID := range fighterClasses {
if GetBaseClass(classID) != ClassFighter {
t.Errorf("GetArchetypeClasses(ClassFighter) returned non-fighter class %d", classID)
}
}
// Test Mage archetype
mageClasses := GetArchetypeClasses(ClassMage)
expectedMageCount := 9 // Sorcerer through Necromancer (22-30)
if len(mageClasses) != expectedMageCount {
t.Errorf("GetArchetypeClasses(ClassMage) returned %d classes, want %d", len(mageClasses), expectedMageCount)
}
}

View File

@ -1,30 +0,0 @@
package collections
import (
"testing"
)
func BenchmarkCollectionCreation(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}
func BenchmarkMasterListOperations(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}
func BenchmarkCollectionMemory(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}
func BenchmarkConcurrentAccess(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}
func BenchmarkCollectionSearch(b *testing.B) {
b.Skip("Skipping benchmark - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement benchmarks
}

View File

@ -1,40 +0,0 @@
package collections
import (
"testing"
)
func TestNew(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestNewWithData(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestCollectionGetters(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestCollectionSetters(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestCollectionConcurrency(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestCollectionThreadSafety(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestCollectionBatch(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}

View File

@ -1,50 +0,0 @@
package collections
import (
"testing"
)
func TestNewMasterList(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListOperations(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListConcurrency(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListFiltering(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListBatchOperations(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListSearch(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListMemoryUsage(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListPerformance(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}
func TestMasterListEdgeCases(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database and implement tests
}

View File

@ -1,327 +0,0 @@
# EQ2Go Command System
The EQ2Go command system provides a comprehensive framework for handling player commands, admin commands, and console commands in the EverQuest II server emulator. This system is converted from the original C++ EQ2EMu codebase while leveraging modern Go patterns and practices.
## Architecture Overview
The command system consists of several key components:
- **CommandManager**: Central registry for all commands with thread-safe operations
- **CommandContext**: Execution context providing access to players, zones, arguments, and messaging
- **Command Types**: Different command categories (Player, Admin, Console, etc.)
- **Subcommand Support**: Hierarchical command structure for complex operations
## Core Components
### CommandManager
The `CommandManager` is the central hub that:
- Registers commands by name with case-insensitive lookup
- Manages subcommands under parent commands
- Executes commands with proper admin level checking
- Parses raw command strings into structured contexts
### CommandContext
The `CommandContext` provides:
- Type-safe argument parsing (string, int, float, bool)
- Message queuing with channel and color support
- Validation helpers for argument counts and requirements
- Access to game objects (player, zone, target)
- Result storage for complex operations
### Command Types
Commands are categorized by type:
- **Player Commands** (`CommandTypePlayer`): Basic player actions like `/say`, `/tell`, `/who`
- **Admin Commands** (`CommandTypeAdmin`): GM/Admin tools like `/kick`, `/ban`, `/summon`
- **Console Commands** (`CommandTypeConsole`): Server management from console
- **Specialized Types**: Spawn, Zone, Guild, Item, and Quest commands
## Admin Levels
The system supports four admin levels:
- **Player** (0): Basic player commands
- **Guide** (100): Helper/guide commands
- **GM** (200): Game master commands
- **Admin** (300): Full administrative access
## Usage Examples
### Basic Command Registration
```go
// Initialize the command system
cm, err := InitializeCommands()
if err != nil {
log.Fatalf("Failed to initialize commands: %v", err)
}
// Register a custom command
customCmd := &Command{
Name: "custom",
Type: CommandTypePlayer,
Description: "A custom command",
Usage: "/custom <arg>",
RequiredLevel: AdminLevelPlayer,
Handler: func(ctx *CommandContext) error {
if err := ctx.ValidateArgumentCount(1, 1); err != nil {
ctx.AddErrorMessage("Usage: /custom <arg>")
return nil
}
arg := ctx.Arguments[0]
ctx.AddStatusMessage(fmt.Sprintf("You used custom command with: %s", arg))
return nil
},
}
err = cm.Register(customCmd)
if err != nil {
log.Printf("Failed to register custom command: %v", err)
}
```
### Executing Commands
```go
// Parse and execute a command from a client
err := ExecuteCommand(cm, "/say Hello everyone!", client)
if err != nil {
log.Printf("Command execution failed: %v", err)
}
```
### Subcommand Registration
```go
// Register a parent command
parentCmd := &Command{
Name: "manage",
Type: CommandTypeAdmin,
RequiredLevel: AdminLevelGM,
Handler: func(ctx *CommandContext) error {
ctx.AddErrorMessage("Usage: /manage <player|item|zone> [options]")
return nil
},
}
cm.Register(parentCmd)
// Register subcommands
playerSubCmd := &Command{
Name: "player",
Type: CommandTypeAdmin,
RequiredLevel: AdminLevelGM,
Handler: func(ctx *CommandContext) error {
// Handle player management
return nil
},
}
cm.RegisterSubCommand("manage", playerSubCmd)
```
## Command Implementation
### Player Commands
Located in `player.go`, these include:
- **Communication**: `/say`, `/tell`, `/yell`, `/shout`, `/ooc`, `/emote`
- **Group Management**: `/group`, `/groupsay`, `/gsay`
- **Guild Management**: `/guild`, `/guildsay`, `/officersay`
- **Information**: `/who`, `/time`, `/location`, `/consider`
- **Character**: `/afk`, `/anon`, `/lfg`, `/inventory`
- **Utilities**: `/help`, `/quit`, `/trade`, `/quest`
### Admin Commands
Located in `admin.go`, these include:
- **Player Management**: `/kick`, `/ban`, `/unban`, `/summon`, `/goto`
- **Communication**: `/broadcast`, `/announce`
- **Spawn Management**: `/spawn`, `/npc`
- **Item Management**: `/item`, `/giveitem`
- **Character Modification**: `/modify`, `/setlevel`, `/setclass`
- **Server Management**: `/reload`, `/shutdown`, `/cancelshutdown`
- **GM Utilities**: `/invisible`, `/invulnerable`, `/speed`, `/flymode`
- **Information**: `/info`, `/version`
### Console Commands
Located in `console.go`, these include prefixed versions of admin commands for console use:
- **Player Management**: `console_ban`, `console_kick`, `console_unban`
- **Communication**: `console_broadcast`, `console_announce`, `console_tell`
- **Information**: `console_guild`, `player`, `console_zone`, `console_who`
- **Server Management**: `console_reload`, `console_shutdown`, `exit`
- **MOTD Management**: `getmotd`, `setmotd`
- **Rules Management**: `rules`
## Message Channels and Colors
Commands can send messages through various channels:
### Channels
- `ChannelSay` (8): Local say messages
- `ChannelTell` (28): Private messages
- `ChannelBroadcast` (92): Server-wide broadcasts
- `ChannelError` (3): Error messages
- `ChannelStatus` (4): Status updates
### Colors
- `ColorWhite` (254): Standard text
- `ColorRed` (3): Errors and warnings
- `ColorYellow` (5): Status messages
- `ColorChatRelation` (4): Relationship text
## Error Handling
The command system provides comprehensive error handling:
```go
func HandleExampleCommand(ctx *CommandContext) error {
// Validate player is present
if err := ctx.RequirePlayer(); err != nil {
return err
}
// Validate argument count
if err := ctx.ValidateArgumentCount(1, 3); err != nil {
ctx.AddErrorMessage("Usage: /example <required> [optional1] [optional2]")
return nil
}
// Validate admin level
if err := ctx.RequireAdminLevel(AdminLevelGM); err != nil {
return err
}
// Command logic here...
return nil
}
```
## Testing
The command system includes comprehensive tests in `commands_test.go`:
```bash
# Run all command tests
go test ./internal/commands -v
# Run specific test
go test ./internal/commands -run TestCommandManager_Execute
```
Tests cover:
- Command registration and retrieval
- Argument parsing and validation
- Message handling
- Admin level checking
- Subcommand functionality
- Error conditions
## Integration with Server
To integrate the command system with the server:
```go
// Initialize commands during server startup
commandManager, err := commands.InitializeCommands()
if err != nil {
log.Fatalf("Failed to initialize commands: %v", err)
}
// Handle incoming command from client
func handleClientCommand(client ClientInterface, rawCommand string) {
err := commands.ExecuteCommand(commandManager, rawCommand, client)
if err != nil {
log.Printf("Command execution error: %v", err)
}
}
```
## Thread Safety
The command system is designed to be thread-safe:
- CommandManager uses read/write mutexes for concurrent access
- CommandContext uses mutexes for message and result storage
- All operations are safe for concurrent use
## Extension Points
The system is designed for easy extension:
### Custom Command Types
```go
const CommandTypeCustom CommandType = 100
func (ct CommandType) String() string {
switch ct {
case CommandTypeCustom:
return "custom"
default:
return CommandType(ct).String()
}
}
```
### Custom Handlers
```go
func MyCustomHandler(ctx *CommandContext) error {
// Custom command logic
ctx.AddStatusMessage("Custom command executed!")
return nil
}
```
## Performance Considerations
- Commands are indexed by name in a hash map for O(1) lookup
- Case-insensitive matching uses normalized lowercase keys
- Message batching reduces client communication overhead
- Argument parsing is lazy and type-safe
## Migration from C++
Key differences from the original C++ implementation:
1. **Type Safety**: Go's type system prevents many runtime errors
2. **Memory Management**: Automatic garbage collection eliminates memory leaks
3. **Concurrency**: Native goroutine support for concurrent operations
4. **Error Handling**: Explicit error returns instead of exceptions
5. **Testing**: Built-in testing framework with comprehensive coverage
## Future Enhancements
Planned improvements:
- Lua script integration for dynamic commands
- Command aliasing system
- Advanced permission system
- Command cooldowns and rate limiting
- Audit logging for admin commands
- Dynamic command loading/unloading
## Troubleshooting
### Common Issues
**Command Not Found**: Ensure command is registered and name matches exactly
**Insufficient Privileges**: Check admin level requirements
**Argument Validation**: Use proper argument count validation
**Interface Errors**: Ensure client implements ClientInterface correctly
### Debug Output
Enable debug output for command execution:
```go
ctx.AddDefaultMessage(fmt.Sprintf("Debug: Command %s executed with %d args",
ctx.CommandName, ctx.ArgumentCount()))
```
## Conclusion
The EQ2Go command system provides a robust, extensible framework for handling all types of game commands while maintaining compatibility with the original EverQuest II protocol. The system's modular design and comprehensive testing ensure reliability and ease of maintenance.

View File

@ -1,614 +0,0 @@
package commands
import (
"testing"
"eq2emu/internal/entity"
"eq2emu/internal/spawn"
)
// Mock implementations for testing
type mockClient struct {
player *entity.Entity
accountID int32
characterID int32
adminLevel int
name string
zone *mockZone
messages []mockMessage
}
type mockMessage struct {
channel int
color int
message string
}
func (mc *mockClient) GetPlayer() *entity.Entity { return mc.player }
func (mc *mockClient) GetAccountID() int32 { return mc.accountID }
func (mc *mockClient) GetCharacterID() int32 { return mc.characterID }
func (mc *mockClient) GetAdminLevel() int { return mc.adminLevel }
func (mc *mockClient) GetName() string { return mc.name }
func (mc *mockClient) IsInZone() bool { return mc.zone != nil }
func (mc *mockClient) GetZone() ZoneInterface { return mc.zone }
func (mc *mockClient) SendMessage(channel, color int, message string) {
mc.messages = append(mc.messages, mockMessage{channel, color, message})
}
func (mc *mockClient) SendPopupMessage(message string) {}
func (mc *mockClient) Disconnect() {}
type mockZone struct {
id int32
name string
description string
players []*entity.Entity
}
func (mz *mockZone) GetID() int32 { return mz.id }
func (mz *mockZone) GetName() string { return mz.name }
func (mz *mockZone) GetDescription() string { return mz.description }
func (mz *mockZone) GetPlayers() []*entity.Entity { return mz.players }
func (mz *mockZone) Shutdown() {}
func (mz *mockZone) SendZoneMessage(channel, color int, message string) {}
func (mz *mockZone) GetSpawnByName(name string) *spawn.Spawn { return nil }
func (mz *mockZone) GetSpawnByID(id int32) *spawn.Spawn { return nil }
func TestCommandManager_Register(t *testing.T) {
cm := NewCommandManager()
// Test registering a valid command
cmd := &Command{
Name: "test",
Type: CommandTypePlayer,
Description: "Test command",
Usage: "/test",
RequiredLevel: AdminLevelPlayer,
Handler: func(ctx *CommandContext) error {
ctx.AddStatusMessage("Test executed")
return nil
},
}
err := cm.Register(cmd)
if err != nil {
t.Errorf("Failed to register valid command: %v", err)
}
// Test registering duplicate command
err = cm.Register(cmd)
if err == nil {
t.Error("Expected error when registering duplicate command")
}
// Test registering nil command
err = cm.Register(nil)
if err == nil {
t.Error("Expected error when registering nil command")
}
// Test registering command with empty name
invalidCmd := &Command{
Name: "",
Handler: func(ctx *CommandContext) error { return nil },
}
err = cm.Register(invalidCmd)
if err == nil {
t.Error("Expected error when registering command with empty name")
}
// Test registering command with nil handler
invalidCmd2 := &Command{
Name: "invalid",
Handler: nil,
}
err = cm.Register(invalidCmd2)
if err == nil {
t.Error("Expected error when registering command with nil handler")
}
}
func TestCommandManager_HasCommand(t *testing.T) {
cm := NewCommandManager()
cmd := &Command{
Name: "testcmd",
Type: CommandTypePlayer,
RequiredLevel: AdminLevelPlayer,
Handler: func(ctx *CommandContext) error { return nil },
}
// Test command doesn't exist initially
if cm.HasCommand("testcmd") {
t.Error("Command should not exist initially")
}
// Register command
err := cm.Register(cmd)
if err != nil {
t.Fatalf("Failed to register command: %v", err)
}
// Test command exists after registration
if !cm.HasCommand("testcmd") {
t.Error("Command should exist after registration")
}
// Test case insensitivity
if !cm.HasCommand("TESTCMD") {
t.Error("Command lookup should be case insensitive")
}
}
func TestCommandManager_GetCommand(t *testing.T) {
cm := NewCommandManager()
cmd := &Command{
Name: "getcmd",
Type: CommandTypeAdmin,
RequiredLevel: AdminLevelGM,
Handler: func(ctx *CommandContext) error { return nil },
}
// Test getting non-existent command
_, exists := cm.GetCommand("getcmd")
if exists {
t.Error("Command should not exist initially")
}
// Register command
err := cm.Register(cmd)
if err != nil {
t.Fatalf("Failed to register command: %v", err)
}
// Test getting existing command
retrievedCmd, exists := cm.GetCommand("getcmd")
if !exists {
t.Error("Command should exist after registration")
}
if retrievedCmd.Name != "getcmd" {
t.Errorf("Expected command name 'getcmd', got '%s'", retrievedCmd.Name)
}
if retrievedCmd.Type != CommandTypeAdmin {
t.Errorf("Expected command type Admin, got %v", retrievedCmd.Type)
}
// Test case insensitivity
_, exists = cm.GetCommand("GETCMD")
if !exists {
t.Error("Command lookup should be case insensitive")
}
}
func TestCommandManager_Execute(t *testing.T) {
cm := NewCommandManager()
executed := false
cmd := &Command{
Name: "exectest",
Type: CommandTypePlayer,
RequiredLevel: AdminLevelPlayer,
Handler: func(ctx *CommandContext) error {
executed = true
ctx.AddStatusMessage("Command executed successfully")
return nil
},
}
err := cm.Register(cmd)
if err != nil {
t.Fatalf("Failed to register command: %v", err)
}
// Test successful execution
ctx := NewCommandContext(CommandTypePlayer, "exectest", []string{})
ctx.AdminLevel = AdminLevelPlayer
err = cm.Execute(ctx)
if err != nil {
t.Errorf("Command execution failed: %v", err)
}
if !executed {
t.Error("Command handler was not executed")
}
// Test insufficient admin level
ctx2 := NewCommandContext(CommandTypePlayer, "exectest", []string{})
ctx2.AdminLevel = 0
// Register admin-only command
adminCmd := &Command{
Name: "admintest",
Type: CommandTypeAdmin,
RequiredLevel: AdminLevelAdmin,
Handler: func(ctx *CommandContext) error {
return nil
},
}
err = cm.Register(adminCmd)
if err != nil {
t.Fatalf("Failed to register admin command: %v", err)
}
ctx3 := NewCommandContext(CommandTypeAdmin, "admintest", []string{})
ctx3.AdminLevel = AdminLevelPlayer
err = cm.Execute(ctx3)
if err == nil {
t.Error("Expected error for insufficient admin level")
}
// Test unknown command
ctx4 := NewCommandContext(CommandTypePlayer, "unknown", []string{})
err = cm.Execute(ctx4)
if err == nil {
t.Error("Expected error for unknown command")
}
}
func TestCommandManager_RegisterSubCommand(t *testing.T) {
cm := NewCommandManager()
// Register parent command
parentCmd := &Command{
Name: "parent",
Type: CommandTypeAdmin,
RequiredLevel: AdminLevelGM,
Handler: func(ctx *CommandContext) error {
return nil
},
}
err := cm.Register(parentCmd)
if err != nil {
t.Fatalf("Failed to register parent command: %v", err)
}
// Register subcommand
subCmd := &Command{
Name: "sub",
Type: CommandTypeAdmin,
RequiredLevel: AdminLevelGM,
Handler: func(ctx *CommandContext) error {
ctx.AddStatusMessage("Subcommand executed")
return nil
},
}
err = cm.RegisterSubCommand("parent", subCmd)
if err != nil {
t.Errorf("Failed to register subcommand: %v", err)
}
// Test registering subcommand for non-existent parent
err = cm.RegisterSubCommand("nonexistent", subCmd)
if err == nil {
t.Error("Expected error when registering subcommand for non-existent parent")
}
// Test registering duplicate subcommand
err = cm.RegisterSubCommand("parent", subCmd)
if err == nil {
t.Error("Expected error when registering duplicate subcommand")
}
}
func TestCommandManager_ParseCommand(t *testing.T) {
cm := NewCommandManager()
client := &mockClient{
name: "TestPlayer",
adminLevel: AdminLevelPlayer,
}
// Test parsing valid command
ctx := cm.ParseCommand("/say Hello world", client)
if ctx == nil {
t.Error("Expected non-nil context for valid command")
}
if ctx.CommandName != "say" {
t.Errorf("Expected command name 'say', got '%s'", ctx.CommandName)
}
if len(ctx.Arguments) != 2 {
t.Errorf("Expected 2 arguments, got %d", len(ctx.Arguments))
}
if ctx.Arguments[0] != "Hello" || ctx.Arguments[1] != "world" {
t.Errorf("Unexpected arguments: %v", ctx.Arguments)
}
if ctx.RawArguments != "Hello world" {
t.Errorf("Expected raw arguments 'Hello world', got '%s'", ctx.RawArguments)
}
// Test parsing command without slash
ctx2 := cm.ParseCommand("tell Player Hi there", client)
if ctx2 == nil {
t.Error("Expected non-nil context for command without slash")
}
if ctx2.CommandName != "tell" {
t.Errorf("Expected command name 'tell', got '%s'", ctx2.CommandName)
}
// Test parsing empty command
ctx3 := cm.ParseCommand("", client)
if ctx3 != nil {
t.Error("Expected nil context for empty command")
}
// Test parsing whitespace-only command
ctx4 := cm.ParseCommand(" ", client)
if ctx4 != nil {
t.Error("Expected nil context for whitespace-only command")
}
}
func TestCommandContext_ValidateArgumentCount(t *testing.T) {
ctx := NewCommandContext(CommandTypePlayer, "test", []string{"arg1", "arg2", "arg3"})
// Test valid argument count (exactly 3)
err := ctx.ValidateArgumentCount(3, 3)
if err != nil {
t.Errorf("Expected no error for exact argument count, got: %v", err)
}
// Test valid argument count (range 2-4)
err = ctx.ValidateArgumentCount(2, 4)
if err != nil {
t.Errorf("Expected no error for argument count in range, got: %v", err)
}
// Test too few arguments
err = ctx.ValidateArgumentCount(5, 6)
if err == nil {
t.Error("Expected error for too few arguments")
}
// Test too many arguments
err = ctx.ValidateArgumentCount(1, 2)
if err == nil {
t.Error("Expected error for too many arguments")
}
// Test unlimited max arguments (-1)
err = ctx.ValidateArgumentCount(2, -1)
if err != nil {
t.Errorf("Expected no error for unlimited max arguments, got: %v", err)
}
}
func TestCommandContext_GetArgument(t *testing.T) {
ctx := NewCommandContext(CommandTypePlayer, "test", []string{"first", "second", "third"})
// Test valid indices
arg, exists := ctx.GetArgument(0)
if !exists || arg != "first" {
t.Errorf("Expected 'first', got '%s' (exists: %v)", arg, exists)
}
arg, exists = ctx.GetArgument(2)
if !exists || arg != "third" {
t.Errorf("Expected 'third', got '%s' (exists: %v)", arg, exists)
}
// Test invalid indices
_, exists = ctx.GetArgument(-1)
if exists {
t.Error("Expected false for negative index")
}
_, exists = ctx.GetArgument(3)
if exists {
t.Error("Expected false for out-of-bounds index")
}
}
func TestCommandContext_GetArgumentInt(t *testing.T) {
ctx := NewCommandContext(CommandTypePlayer, "test", []string{"123", "not_a_number", "-456"})
// Test valid integer
val := ctx.GetArgumentInt(0, 999)
if val != 123 {
t.Errorf("Expected 123, got %d", val)
}
// Test invalid integer (should return default)
val = ctx.GetArgumentInt(1, 999)
if val != 999 {
t.Errorf("Expected default value 999, got %d", val)
}
// Test negative integer
val = ctx.GetArgumentInt(2, 999)
if val != -456 {
t.Errorf("Expected -456, got %d", val)
}
// Test out-of-bounds index (should return default)
val = ctx.GetArgumentInt(5, 999)
if val != 999 {
t.Errorf("Expected default value 999, got %d", val)
}
}
func TestCommandContext_GetArgumentFloat(t *testing.T) {
ctx := NewCommandContext(CommandTypePlayer, "test", []string{"12.34", "not_a_number", "-56.78"})
// Test valid float
val := ctx.GetArgumentFloat(0, 999.0)
if val != 12.34 {
t.Errorf("Expected 12.34, got %f", val)
}
// Test invalid float (should return default)
val = ctx.GetArgumentFloat(1, 999.0)
if val != 999.0 {
t.Errorf("Expected default value 999.0, got %f", val)
}
// Test negative float
val = ctx.GetArgumentFloat(2, 999.0)
if val != -56.78 {
t.Errorf("Expected -56.78, got %f", val)
}
}
func TestCommandContext_GetArgumentBool(t *testing.T) {
ctx := NewCommandContext(CommandTypePlayer, "test",
[]string{"true", "false", "yes", "no", "on", "off", "1", "0", "invalid"})
tests := []struct {
index int
expected bool
}{
{0, true}, // "true"
{1, false}, // "false"
{2, true}, // "yes"
{3, false}, // "no"
{4, true}, // "on"
{5, false}, // "off"
{6, true}, // "1"
{7, false}, // "0"
}
for _, test := range tests {
val := ctx.GetArgumentBool(test.index, !test.expected) // Use opposite as default
if val != test.expected {
t.Errorf("Index %d: expected %v, got %v", test.index, test.expected, val)
}
}
// Test invalid boolean (should return default)
val := ctx.GetArgumentBool(8, true)
if val != true {
t.Errorf("Expected default value true for invalid boolean, got %v", val)
}
}
func TestCommandContext_Messages(t *testing.T) {
ctx := NewCommandContext(CommandTypePlayer, "test", []string{})
// Test adding messages
ctx.AddDefaultMessage("Default message")
ctx.AddErrorMessage("Error message")
ctx.AddStatusMessage("Status message")
ctx.AddMessage(ChannelSay, ColorWhite, "Custom message")
if len(ctx.Messages) != 4 {
t.Errorf("Expected 4 messages, got %d", len(ctx.Messages))
}
// Check message types
expectedMessages := []struct {
channel int
color int
message string
}{
{ChannelDefault, ColorWhite, "Default message"},
{ChannelError, ColorRed, "Error message"},
{ChannelStatus, ColorYellow, "Status message"},
{ChannelSay, ColorWhite, "Custom message"},
}
for i, expected := range expectedMessages {
msg := ctx.Messages[i]
if msg.Channel != expected.channel {
t.Errorf("Message %d: expected channel %d, got %d", i, expected.channel, msg.Channel)
}
if msg.Color != expected.color {
t.Errorf("Message %d: expected color %d, got %d", i, expected.color, msg.Color)
}
if msg.Message != expected.message {
t.Errorf("Message %d: expected message '%s', got '%s'", i, expected.message, msg.Message)
}
}
// Test clearing messages
ctx.ClearMessages()
if len(ctx.Messages) != 0 {
t.Errorf("Expected 0 messages after clearing, got %d", len(ctx.Messages))
}
}
func TestInitializeCommands(t *testing.T) {
cm, err := InitializeCommands()
if err != nil {
t.Fatalf("Failed to initialize commands: %v", err)
}
if cm == nil {
t.Fatal("Expected non-nil command manager")
}
// Test that some basic commands are registered
expectedCommands := []string{"say", "tell", "kick", "ban", "reload", "shutdown", "console_ban", "console_kick"}
for _, cmdName := range expectedCommands {
if !cm.HasCommand(cmdName) {
t.Errorf("Expected command '%s' to be registered", cmdName)
}
}
// Test command counts by type
playerCommands := cm.ListCommandsByType(CommandTypePlayer)
adminCommands := cm.ListCommandsByType(CommandTypeAdmin)
consoleCommands := cm.ListCommandsByType(CommandTypeConsole)
if len(playerCommands) == 0 {
t.Error("Expected player commands to be registered")
}
if len(adminCommands) == 0 {
t.Error("Expected admin commands to be registered")
}
if len(consoleCommands) == 0 {
t.Error("Expected console commands to be registered")
}
}
func TestGetAvailableCommands(t *testing.T) {
cm, err := InitializeCommands()
if err != nil {
t.Fatalf("Failed to initialize commands: %v", err)
}
// Test player level commands
playerCommands := GetAvailableCommands(cm, AdminLevelPlayer)
if len(playerCommands) == 0 {
t.Error("Expected player-level commands to be available")
}
// Test admin level commands
adminCommands := GetAvailableCommands(cm, AdminLevelAdmin)
if len(adminCommands) <= len(playerCommands) {
t.Error("Expected admin to have more commands available than player")
}
// Verify all player commands are also available to admin
playerCommandMap := make(map[string]bool)
for _, cmd := range playerCommands {
playerCommandMap[cmd] = true
}
for _, cmd := range playerCommands {
found := false
for _, adminCmd := range adminCommands {
if adminCmd == cmd {
found = true
break
}
}
if !found {
t.Errorf("Player command '%s' should be available to admin", cmd)
}
}
}

View File

@ -1,122 +0,0 @@
package database
import (
"testing"
_ "github.com/go-sql-driver/mysql"
)
func TestNewMySQL(t *testing.T) {
// Skip this test if no MySQL test database is available
t.Skip("Skipping MySQL test - requires MySQL test database")
// Example test for when MySQL is available:
// db, err := NewMySQL("test_user:test_pass@tcp(localhost:3306)/test_db")
// if err != nil {
// t.Fatalf("Failed to create MySQL database: %v", err)
// }
// defer db.Close()
//
// // Test database type
// if db.GetType() != MySQL {
// t.Errorf("Expected MySQL database type, got %v", db.GetType())
// }
}
func TestConfigValidation(t *testing.T) {
tests := []struct {
name string
config Config
wantErr bool
}{
{
name: "valid_mysql_config",
config: Config{
DSN: "user:password@tcp(localhost:3306)/database",
},
wantErr: false, // Will fail without actual MySQL, but config is valid
},
{
name: "empty_dsn",
config: Config{
DSN: "",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db, _ := New(tt.config)
// We expect connection errors since we don't have a test MySQL
// but we can test that the configuration is handled properly
if db != nil {
db.Close()
}
// For now, just ensure no panics occur
})
}
}
func TestDatabaseTypeMethods(t *testing.T) {
// Test with mock config (will fail to connect but won't panic)
config := Config{
DSN: "test:test@tcp(localhost:3306)/test",
}
db, err := New(config)
if err != nil {
// Expected - no actual MySQL server
t.Logf("Expected connection error: %v", err)
return
}
defer db.Close()
if db.GetType() != MySQL {
t.Errorf("Expected MySQL type, got %v", db.GetType())
}
}
func TestDatabaseMethods(t *testing.T) {
// Skip actual database tests without MySQL
t.Skip("Skipping database method tests - requires MySQL test database")
// Example tests for when MySQL is available:
// db, err := NewMySQL("test_user:test_pass@tcp(localhost:3306)/test_db")
// if err != nil {
// t.Fatalf("Failed to create database: %v", err)
// }
// defer db.Close()
//
// // Test basic operations
// _, err = db.Exec("CREATE TEMPORARY TABLE test (id INT PRIMARY KEY, name VARCHAR(255))")
// if err != nil {
// t.Fatalf("Failed to create test table: %v", err)
// }
//
// // Test insert
// result, err := db.Exec("INSERT INTO test (id, name) VALUES (?, ?)", 1, "test_value")
// if err != nil {
// t.Fatalf("Failed to insert test data: %v", err)
// }
//
// // Test query
// rows, err := db.Query("SELECT name FROM test WHERE id = ?", 1)
// if err != nil {
// t.Fatalf("Failed to query test data: %v", err)
// }
// defer rows.Close()
//
// if !rows.Next() {
// t.Fatal("No rows returned from query")
// }
//
// var name string
// if err := rows.Scan(&name); err != nil {
// t.Fatalf("Failed to scan result: %v", err)
// }
//
// if name != "test_value" {
// t.Errorf("Expected 'test_value', got '%s'", name)
// }
}

View File

@ -1,505 +0,0 @@
package entity
import (
"fmt"
"math/rand"
"testing"
"time"
)
// BenchmarkEntityCreation measures entity creation performance
func BenchmarkEntityCreation(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
entity := NewEntity()
_ = entity
}
})
}
// BenchmarkEntityCombatState measures combat state operations
func BenchmarkEntityCombatState(b *testing.B) {
entity := NewEntity()
b.Run("Sequential", func(b *testing.B) {
for i := 0; i < b.N; i++ {
entity.SetInCombat(true)
_ = entity.IsInCombat()
entity.SetInCombat(false)
}
})
b.Run("Parallel", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
entity.SetInCombat(true)
_ = entity.IsInCombat()
entity.SetInCombat(false)
}
})
})
}
// BenchmarkEntityCastingState measures casting state operations
func BenchmarkEntityCastingState(b *testing.B) {
entity := NewEntity()
b.Run("Sequential", func(b *testing.B) {
for i := 0; i < b.N; i++ {
entity.SetCasting(true)
_ = entity.IsCasting()
entity.SetCasting(false)
}
})
b.Run("Parallel", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
entity.SetCasting(true)
_ = entity.IsCasting()
entity.SetCasting(false)
}
})
})
}
// BenchmarkEntityStatCalculations measures stat calculation performance
func BenchmarkEntityStatCalculations(b *testing.B) {
entity := NewEntity()
info := entity.GetInfoStruct()
// Set up some base stats
info.SetStr(100.0)
info.SetSta(100.0)
info.SetAgi(100.0)
info.SetWis(100.0)
info.SetIntel(100.0)
b.Run("GetStats", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = entity.GetStr()
_ = entity.GetSta()
_ = entity.GetAgi()
_ = entity.GetWis()
_ = entity.GetIntel()
}
})
b.Run("GetStatsParallel", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = entity.GetStr()
_ = entity.GetSta()
_ = entity.GetAgi()
_ = entity.GetWis()
_ = entity.GetIntel()
}
})
})
b.Run("GetPrimaryStat", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = entity.GetPrimaryStat()
}
})
b.Run("CalculateBonuses", func(b *testing.B) {
for i := 0; i < b.N; i++ {
entity.CalculateBonuses()
}
})
}
// BenchmarkEntitySpellEffects measures spell effect operations
func BenchmarkEntitySpellEffects(b *testing.B) {
entity := NewEntity()
b.Run("AddRemoveSpellEffect", func(b *testing.B) {
for i := 0; i < b.N; i++ {
spellID := int32(i + 1000)
entity.AddSpellEffect(spellID, 123, 30.0)
entity.RemoveSpellEffect(spellID)
}
})
b.Run("AddRemoveSpellEffectParallel", func(b *testing.B) {
var counter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
spellID := int32(counter + 1000)
counter++
entity.AddSpellEffect(spellID, 123, 30.0)
entity.RemoveSpellEffect(spellID)
}
})
})
}
// BenchmarkEntityMaintainedSpells measures maintained spell operations
func BenchmarkEntityMaintainedSpells(b *testing.B) {
entity := NewEntity()
info := entity.GetInfoStruct()
info.SetMaxConcentration(1000) // Large pool for benchmarking
b.Run("AddRemoveMaintainedSpell", func(b *testing.B) {
for i := 0; i < b.N; i++ {
spellID := int32(i + 2000)
if entity.AddMaintainedSpell("Benchmark Spell", spellID, 60.0, 1) {
entity.RemoveMaintainedSpell(spellID)
}
}
})
b.Run("AddRemoveMaintainedSpellParallel", func(b *testing.B) {
var counter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
spellID := int32(counter + 2000)
counter++
if entity.AddMaintainedSpell("Benchmark Spell", spellID, 60.0, 1) {
entity.RemoveMaintainedSpell(spellID)
}
}
})
})
}
// BenchmarkInfoStructBasicOps measures basic InfoStruct operations
func BenchmarkInfoStructBasicOps(b *testing.B) {
info := NewInfoStruct()
b.Run("SetGetName", func(b *testing.B) {
for i := 0; i < b.N; i++ {
info.SetName("BenchmarkCharacter")
_ = info.GetName()
}
})
b.Run("SetGetNameParallel", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
info.SetName("BenchmarkCharacter")
_ = info.GetName()
}
})
})
b.Run("SetGetLevel", func(b *testing.B) {
for i := 0; i < b.N; i++ {
info.SetLevel(int16(i % 100))
_ = info.GetLevel()
}
})
b.Run("SetGetStats", func(b *testing.B) {
for i := 0; i < b.N; i++ {
val := float32(i % 1000)
info.SetStr(val)
info.SetSta(val)
info.SetAgi(val)
info.SetWis(val)
info.SetIntel(val)
_ = info.GetStr()
_ = info.GetSta()
_ = info.GetAgi()
_ = info.GetWis()
_ = info.GetIntel()
}
})
}
// BenchmarkInfoStructConcentration measures concentration operations
func BenchmarkInfoStructConcentration(b *testing.B) {
info := NewInfoStruct()
info.SetMaxConcentration(1000)
b.Run("AddRemoveConcentration", func(b *testing.B) {
for i := 0; i < b.N; i++ {
amount := int16(i%10 + 1)
if info.AddConcentration(amount) {
info.RemoveConcentration(amount)
}
}
})
b.Run("AddRemoveConcentrationParallel", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
amount := int16(1) // Use small amount to reduce contention
if info.AddConcentration(amount) {
info.RemoveConcentration(amount)
}
}
})
})
}
// BenchmarkInfoStructCoins measures coin operations
func BenchmarkInfoStructCoins(b *testing.B) {
info := NewInfoStruct()
b.Run("AddRemoveCoins", func(b *testing.B) {
for i := 0; i < b.N; i++ {
amount := int32(i%10000 + 1)
info.AddCoins(amount)
info.RemoveCoins(amount / 2)
}
})
b.Run("AddRemoveCoinsParallel", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
amount := int32(100)
info.AddCoins(amount)
info.RemoveCoins(amount / 2)
}
})
})
b.Run("GetCoins", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = info.GetCoins()
}
})
}
// BenchmarkInfoStructResistances measures resistance operations
func BenchmarkInfoStructResistances(b *testing.B) {
info := NewInfoStruct()
resistTypes := []string{"heat", "cold", "magic", "mental", "divine", "disease", "poison"}
b.Run("SetGetResistances", func(b *testing.B) {
for i := 0; i < b.N; i++ {
resistType := resistTypes[i%len(resistTypes)]
value := int16(i % 100)
info.SetResistance(resistType, value)
_ = info.GetResistance(resistType)
}
})
b.Run("SetGetResistancesParallel", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
resistType := resistTypes[rand.Intn(len(resistTypes))]
value := int16(rand.Intn(100))
info.SetResistance(resistType, value)
_ = info.GetResistance(resistType)
}
})
})
}
// BenchmarkInfoStructClone measures clone operations
func BenchmarkInfoStructClone(b *testing.B) {
info := NewInfoStruct()
// Set up some state to clone
info.SetName("Original Character")
info.SetLevel(50)
info.SetStr(100.0)
info.SetSta(120.0)
info.SetAgi(90.0)
info.SetWis(110.0)
info.SetIntel(105.0)
info.AddConcentration(5)
info.AddCoins(50000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
clone := info.Clone()
_ = clone
}
}
// BenchmarkEntityPetManagement measures pet management operations
func BenchmarkEntityPetManagement(b *testing.B) {
entity := NewEntity()
b.Run("SetGetPet", func(b *testing.B) {
for i := 0; i < b.N; i++ {
pet := NewEntity()
entity.SetPet(pet)
_ = entity.GetPet()
entity.SetPet(nil)
}
})
b.Run("SetGetPetParallel", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
pet := NewEntity()
entity.SetPet(pet)
_ = entity.GetPet()
entity.SetPet(nil)
}
})
})
b.Run("AllPetTypes", func(b *testing.B) {
for i := 0; i < b.N; i++ {
pet := NewEntity()
entity.SetPet(pet)
_ = entity.GetPet()
entity.SetCharmedPet(pet)
_ = entity.GetCharmedPet()
entity.SetDeityPet(pet)
_ = entity.GetDeityPet()
entity.SetCosmeticPet(pet)
_ = entity.GetCosmeticPet()
// Clear all pets
entity.SetPet(nil)
entity.SetCharmedPet(nil)
entity.SetDeityPet(nil)
entity.SetCosmeticPet(nil)
}
})
}
// BenchmarkConcurrentWorkload simulates a realistic concurrent workload
func BenchmarkConcurrentWorkload(b *testing.B) {
numEntities := 100
entities := make([]*Entity, numEntities)
// Create entities
for i := 0; i < numEntities; i++ {
entities[i] = NewEntity()
info := entities[i].GetInfoStruct()
info.SetMaxConcentration(50)
info.SetName("Entity" + string(rune('A'+i%26)))
info.SetLevel(int16(i%100 + 1))
}
b.ResetTimer()
b.Run("MixedOperations", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
entityIdx := rand.Intn(numEntities)
entity := entities[entityIdx]
switch rand.Intn(10) {
case 0, 1: // Combat state changes (20%)
entity.SetInCombat(rand.Intn(2) == 1)
_ = entity.IsInCombat()
case 2, 3: // Stat reads (20%)
_ = entity.GetStr()
_ = entity.GetSta()
_ = entity.GetPrimaryStat()
case 4: // Spell effects (10%)
spellID := int32(rand.Intn(1000) + 10000)
entity.AddSpellEffect(spellID, int32(entityIdx), 30.0)
entity.RemoveSpellEffect(spellID)
case 5: // Maintained spells (10%)
spellID := int32(rand.Intn(100) + 20000)
if entity.AddMaintainedSpell("Workload Spell", spellID, 60.0, 1) {
entity.RemoveMaintainedSpell(spellID)
}
case 6, 7: // InfoStruct operations (20%)
info := entity.GetInfoStruct()
info.SetStr(float32(rand.Intn(200) + 50))
_ = info.GetStr()
case 8: // Coin operations (10%)
info := entity.GetInfoStruct()
info.AddCoins(int32(rand.Intn(1000)))
_ = info.GetCoins()
case 9: // Resistance operations (10%)
info := entity.GetInfoStruct()
resistTypes := []string{"heat", "cold", "magic", "mental"}
resistType := resistTypes[rand.Intn(len(resistTypes))]
info.SetResistance(resistType, int16(rand.Intn(100)))
_ = info.GetResistance(resistType)
}
}
})
})
}
// BenchmarkMemoryAllocation measures memory allocation patterns
func BenchmarkMemoryAllocation(b *testing.B) {
b.Run("EntityAllocation", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
entity := NewEntity()
_ = entity
}
})
b.Run("InfoStructAllocation", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
info := NewInfoStruct()
_ = info
}
})
b.Run("CloneAllocation", func(b *testing.B) {
info := NewInfoStruct()
info.SetName("Test")
info.SetLevel(50)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
clone := info.Clone()
_ = clone
}
})
}
// BenchmarkContention measures performance under high contention
func BenchmarkContention(b *testing.B) {
entity := NewEntity()
info := entity.GetInfoStruct()
info.SetMaxConcentration(10) // Low limit to create contention
b.Run("HighContentionConcentration", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if info.AddConcentration(1) {
// Hold for a brief moment to increase contention
time.Sleep(time.Nanosecond)
info.RemoveConcentration(1)
}
}
})
})
b.Run("HighContentionSpellEffects", func(b *testing.B) {
var spellCounter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
spellID := int32(spellCounter % 100) // Reuse spell IDs to create contention
spellCounter++
entity.AddSpellEffect(spellID, 123, 30.0)
entity.RemoveSpellEffect(spellID)
}
})
})
}
// BenchmarkScalability tests performance as load increases
func BenchmarkScalability(b *testing.B) {
goroutineCounts := []int{1, 2, 4, 8, 16, 32, 64}
for _, numGoroutines := range goroutineCounts {
b.Run(fmt.Sprintf("Goroutines_%d", numGoroutines), func(b *testing.B) {
entity := NewEntity()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
entity.SetInCombat(true)
_ = entity.IsInCombat()
entity.SetInCombat(false)
}
})
})
}
}

View File

@ -1,445 +0,0 @@
package entity
import (
"sync"
"sync/atomic"
"testing"
"time"
)
// TestEntityConcurrencyStress performs intensive concurrent operations to identify race conditions
func TestEntityConcurrencyStress(t *testing.T) {
entity := NewEntity()
info := entity.GetInfoStruct()
info.SetMaxConcentration(1000) // Large concentration pool
var wg sync.WaitGroup
numGoroutines := 100
operationsPerGoroutine := 100
// Test 1: Concurrent combat and casting state changes
t.Run("CombatCastingStates", func(t *testing.T) {
var combatOps, castingOps int64
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
// Combat state operations
entity.SetInCombat(true)
if entity.IsInCombat() {
atomic.AddInt64(&combatOps, 1)
}
entity.SetInCombat(false)
// Casting state operations
entity.SetCasting(true)
if entity.IsCasting() {
atomic.AddInt64(&castingOps, 1)
}
entity.SetCasting(false)
}
}()
}
wg.Wait()
t.Logf("Combat operations: %d, Casting operations: %d", combatOps, castingOps)
})
// Test 2: Concurrent spell effect operations
t.Run("SpellEffects", func(t *testing.T) {
var addOps, removeOps int64
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
spellID := int32(goroutineID*1000 + j)
if entity.AddSpellEffect(spellID, int32(goroutineID), 30.0) {
atomic.AddInt64(&addOps, 1)
}
if entity.RemoveSpellEffect(spellID) {
atomic.AddInt64(&removeOps, 1)
}
}
}(i)
}
wg.Wait()
t.Logf("Spell effect adds: %d, removes: %d", addOps, removeOps)
})
// Test 3: Concurrent maintained spell operations with concentration management
t.Run("MaintainedSpells", func(t *testing.T) {
var addOps, removeOps, concentrationFailures int64
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine/10; j++ { // Fewer ops due to concentration limits
spellID := int32(goroutineID*100 + j + 10000)
if entity.AddMaintainedSpell("Stress Test Spell", spellID, 60.0, 1) {
atomic.AddInt64(&addOps, 1)
// Small delay to increase contention
time.Sleep(time.Microsecond)
if entity.RemoveMaintainedSpell(spellID) {
atomic.AddInt64(&removeOps, 1)
}
} else {
atomic.AddInt64(&concentrationFailures, 1)
}
}
}(i)
}
wg.Wait()
t.Logf("Maintained spell adds: %d, removes: %d, concentration failures: %d",
addOps, removeOps, concentrationFailures)
// Verify concentration was properly managed
currentConc := info.GetCurConcentration()
if currentConc != 0 {
t.Errorf("Expected concentration to be 0 after all operations, got %d", currentConc)
}
})
// Test 4: Concurrent stat calculations with bonuses
t.Run("StatCalculations", func(t *testing.T) {
var statReads int64
// Set some base stats
info.SetStr(100.0)
info.SetSta(100.0)
info.SetAgi(100.0)
info.SetWis(100.0)
info.SetIntel(100.0)
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
// Add some stat bonuses
entity.AddStatBonus(int32(goroutineID*1000+j), 1, float32(j%10))
// Read stats (these involve bonus calculations)
_ = entity.GetStr()
_ = entity.GetSta()
_ = entity.GetAgi()
_ = entity.GetWis()
_ = entity.GetIntel()
_ = entity.GetPrimaryStat()
atomic.AddInt64(&statReads, 6)
// Trigger bonus recalculation
entity.CalculateBonuses()
}
}(i)
}
wg.Wait()
t.Logf("Stat reads: %d", statReads)
})
// Test 5: Concurrent pet management
t.Run("PetManagement", func(t *testing.T) {
var petOps int64
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < operationsPerGoroutine/10; j++ {
pet := NewEntity()
// Test different pet types
switch j % 4 {
case 0:
entity.SetPet(pet)
_ = entity.GetPet()
entity.SetPet(nil)
case 1:
entity.SetCharmedPet(pet)
_ = entity.GetCharmedPet()
entity.SetCharmedPet(nil)
case 2:
entity.SetDeityPet(pet)
_ = entity.GetDeityPet()
entity.SetDeityPet(nil)
case 3:
entity.SetCosmeticPet(pet)
_ = entity.GetCosmeticPet()
entity.SetCosmeticPet(nil)
}
atomic.AddInt64(&petOps, 1)
}
}()
}
wg.Wait()
t.Logf("Pet operations: %d", petOps)
})
}
// TestInfoStructConcurrencyStress performs intensive concurrent operations on InfoStruct
func TestInfoStructConcurrencyStress(t *testing.T) {
info := NewInfoStruct()
info.SetMaxConcentration(1000)
var wg sync.WaitGroup
numGoroutines := 100
operationsPerGoroutine := 100
// Test 1: Concurrent basic property access
t.Run("BasicProperties", func(t *testing.T) {
var nameOps, levelOps, statOps int64
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
// Name operations
info.SetName("TestChar" + string(rune('A'+goroutineID%26)))
_ = info.GetName()
atomic.AddInt64(&nameOps, 1)
// Level operations
info.SetLevel(int16(j % 100))
_ = info.GetLevel()
atomic.AddInt64(&levelOps, 1)
// Stat operations
info.SetStr(float32(j))
_ = info.GetStr()
atomic.AddInt64(&statOps, 1)
}
}(i)
}
wg.Wait()
t.Logf("Name ops: %d, Level ops: %d, Stat ops: %d", nameOps, levelOps, statOps)
})
// Test 2: Concurrent concentration management
t.Run("ConcentrationManagement", func(t *testing.T) {
var addSuccesses, addFailures, removes int64
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
amount := int16(j%5 + 1) // 1-5 concentration points
if info.AddConcentration(amount) {
atomic.AddInt64(&addSuccesses, 1)
// Small delay to increase contention
time.Sleep(time.Microsecond)
info.RemoveConcentration(amount)
atomic.AddInt64(&removes, 1)
} else {
atomic.AddInt64(&addFailures, 1)
}
}
}()
}
wg.Wait()
t.Logf("Concentration adds: %d, failures: %d, removes: %d",
addSuccesses, addFailures, removes)
// Verify final state
finalConc := info.GetCurConcentration()
if finalConc < 0 || finalConc > info.GetMaxConcentration() {
t.Errorf("Invalid final concentration: %d (max: %d)", finalConc, info.GetMaxConcentration())
}
})
// Test 3: Concurrent coin operations
t.Run("CoinOperations", func(t *testing.T) {
var addOps, removeSuccesses, removeFailures int64
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
amount := int32((goroutineID*1000 + j) % 10000)
// Add coins
info.AddCoins(amount)
atomic.AddInt64(&addOps, 1)
// Try to remove some coins
removeAmount := amount / 2
if info.RemoveCoins(removeAmount) {
atomic.AddInt64(&removeSuccesses, 1)
} else {
atomic.AddInt64(&removeFailures, 1)
}
// Read total coins
_ = info.GetCoins()
}
}(i)
}
wg.Wait()
t.Logf("Coin adds: %d, remove successes: %d, failures: %d",
addOps, removeSuccesses, removeFailures)
// Verify coins are non-negative
finalCoins := info.GetCoins()
if finalCoins < 0 {
t.Errorf("Coins became negative: %d", finalCoins)
}
})
// Test 4: Concurrent resistance operations
t.Run("ResistanceOperations", func(t *testing.T) {
var resistOps int64
resistTypes := []string{"heat", "cold", "magic", "mental", "divine", "disease", "poison"}
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
resistType := resistTypes[j%len(resistTypes)]
value := int16(j % 100)
info.SetResistance(resistType, value)
_ = info.GetResistance(resistType)
atomic.AddInt64(&resistOps, 1)
}
}(i)
}
wg.Wait()
t.Logf("Resistance operations: %d", resistOps)
})
// Test 5: Concurrent clone operations
t.Run("CloneOperations", func(t *testing.T) {
var cloneOps int64
// Set some initial state
info.SetName("Original")
info.SetLevel(50)
info.SetStr(100.0)
info.AddConcentration(5)
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < operationsPerGoroutine/10; j++ { // Fewer clones as they're expensive
clone := info.Clone()
if clone != nil {
// Verify clone independence
clone.SetName("Clone")
if info.GetName() == "Clone" {
t.Errorf("Clone modified original")
}
atomic.AddInt64(&cloneOps, 1)
}
}
}()
}
wg.Wait()
t.Logf("Clone operations: %d", cloneOps)
})
}
// TestRaceConditionDetection uses the race detector to find potential issues
func TestRaceConditionDetection(t *testing.T) {
if testing.Short() {
t.Skip("Skipping race condition test in short mode")
}
entity := NewEntity()
info := entity.GetInfoStruct()
info.SetMaxConcentration(100)
var wg sync.WaitGroup
numGoroutines := 50
// Create a scenario designed to trigger race conditions
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < 50; j++ {
// Mix of read and write operations that could race
entity.SetInCombat(id%2 == 0)
isInCombat := entity.IsInCombat()
entity.SetCasting(j%2 == 0)
isCasting := entity.IsCasting()
// Stats with bonus calculations
info.SetStr(float32(id + j))
str := entity.GetStr()
// Concentration with potential for contention
if info.AddConcentration(1) {
info.RemoveConcentration(1)
}
// Use the values to prevent optimization
_ = isInCombat
_ = isCasting
_ = str
}
}(i)
}
wg.Wait()
}
// Cleanup test to run after stress tests
func TestConcurrencyCleanup(t *testing.T) {
entity := NewEntity()
info := entity.GetInfoStruct()
// Verify entity is in clean state after stress tests
if entity.IsInCombat() {
t.Error("Entity should not be in combat after tests")
}
if entity.IsCasting() {
t.Error("Entity should not be casting after tests")
}
if info.GetCurConcentration() < 0 {
t.Error("Concentration should not be negative")
}
if info.GetCoins() < 0 {
t.Error("Coins should not be negative")
}
}

View File

@ -1,659 +0,0 @@
package entity
import (
"sync"
"testing"
"time"
"eq2emu/internal/spells"
)
func TestNewEntity(t *testing.T) {
entity := NewEntity()
if entity == nil {
t.Fatal("NewEntity returned nil")
}
if entity.Spawn == nil {
t.Error("Expected Spawn to be initialized")
}
if entity.infoStruct == nil {
t.Error("Expected InfoStruct to be initialized")
}
if entity.spellEffectManager == nil {
t.Error("Expected SpellEffectManager to be initialized")
}
// Check default values
if entity.GetMaxSpeed() != 6.0 {
t.Errorf("Expected max speed 6.0, got %f", entity.GetMaxSpeed())
}
if entity.GetBaseSpeed() != 0.0 {
t.Errorf("Expected base speed 0.0, got %f", entity.GetBaseSpeed())
}
if entity.GetSpeedMultiplier() != 1.0 {
t.Errorf("Expected speed multiplier 1.0, got %f", entity.GetSpeedMultiplier())
}
// Check initial states
if entity.IsInCombat() {
t.Error("Expected entity to not be in combat initially")
}
if entity.IsCasting() {
t.Error("Expected entity to not be casting initially")
}
if entity.IsPetDismissing() {
t.Error("Expected pet to not be dismissing initially")
}
if entity.HasSeeInvisSpell() {
t.Error("Expected entity to not have see invisible spell initially")
}
if entity.HasSeeHideSpell() {
t.Error("Expected entity to not have see hidden spell initially")
}
}
func TestEntityIsEntity(t *testing.T) {
entity := NewEntity()
if !entity.IsEntity() {
t.Error("Expected IsEntity to return true")
}
}
func TestEntityInfoStruct(t *testing.T) {
entity := NewEntity()
stats := entity.GetInfoStruct()
if stats == nil {
t.Error("Expected InfoStruct to be initialized")
}
// Test setting a new info struct
newInfo := NewInfoStruct()
newInfo.SetName("Test Entity")
entity.SetInfoStruct(newInfo)
if entity.GetInfoStruct().GetName() != "Test Entity" {
t.Error("Expected info struct to be updated")
}
// Test that nil info struct is ignored
entity.SetInfoStruct(nil)
if entity.GetInfoStruct().GetName() != "Test Entity" {
t.Error("Expected info struct to remain unchanged when setting nil")
}
}
func TestEntityClient(t *testing.T) {
entity := NewEntity()
// Base Entity should return nil for GetClient
client := entity.GetClient()
if client != nil {
t.Error("Expected GetClient to return nil for base Entity")
}
}
func TestEntityCombatState(t *testing.T) {
entity := NewEntity()
// Initial state should be false
if entity.IsInCombat() {
t.Error("Expected entity to not be in combat initially")
}
// Set combat state to true
entity.SetInCombat(true)
if !entity.IsInCombat() {
t.Error("Expected entity to be in combat after setting to true")
}
// Set combat state to false
entity.SetInCombat(false)
if entity.IsInCombat() {
t.Error("Expected entity to not be in combat after setting to false")
}
}
func TestEntityCastingState(t *testing.T) {
entity := NewEntity()
// Initial state should be false
if entity.IsCasting() {
t.Error("Expected entity to not be casting initially")
}
// Set casting state to true
entity.SetCasting(true)
if !entity.IsCasting() {
t.Error("Expected entity to be casting after setting to true")
}
// Set casting state to false
entity.SetCasting(false)
if entity.IsCasting() {
t.Error("Expected entity to not be casting after setting to false")
}
}
func TestEntitySpeedMethods(t *testing.T) {
entity := NewEntity()
// Test max speed
entity.SetMaxSpeed(10.0)
if entity.GetMaxSpeed() != 10.0 {
t.Errorf("Expected max speed 10.0, got %f", entity.GetMaxSpeed())
}
// Test base speed
entity.SetBaseSpeed(5.0)
if entity.GetBaseSpeed() != 5.0 {
t.Errorf("Expected base speed 5.0, got %f", entity.GetBaseSpeed())
}
// Test speed multiplier
entity.SetSpeedMultiplier(2.0)
if entity.GetSpeedMultiplier() != 2.0 {
t.Errorf("Expected speed multiplier 2.0, got %f", entity.GetSpeedMultiplier())
}
// Test effective speed calculation with base speed set
effectiveSpeed := entity.CalculateEffectiveSpeed()
if effectiveSpeed != 10.0 { // 5.0 * 2.0
t.Errorf("Expected effective speed 10.0, got %f", effectiveSpeed)
}
// Test effective speed calculation with zero base speed (should use max speed)
entity.SetBaseSpeed(0.0)
effectiveSpeed = entity.CalculateEffectiveSpeed()
if effectiveSpeed != 20.0 { // 10.0 * 2.0
t.Errorf("Expected effective speed 20.0, got %f", effectiveSpeed)
}
}
func TestEntityPositionTracking(t *testing.T) {
entity := NewEntity()
// Test setting and getting last position
entity.SetLastPosition(100.0, 200.0, 300.0, 1.5)
x, y, z, heading := entity.GetLastPosition()
if x != 100.0 || y != 200.0 || z != 300.0 || heading != 1.5 {
t.Errorf("Expected position (100.0, 200.0, 300.0, 1.5), got (%f, %f, %f, %f)", x, y, z, heading)
}
// Test HasMoved with different positions
// Note: This would require setting the spawn's actual position, which depends on the spawn implementation
// For now, we just test that the method doesn't panic
moved := entity.HasMoved()
_ = moved // We can't easily test the actual movement detection without setting up spawn positions
}
func TestEntityStatCalculation(t *testing.T) {
entity := NewEntity()
info := entity.GetInfoStruct()
// Set base stats
info.SetStr(10.0)
info.SetSta(12.0)
info.SetAgi(8.0)
info.SetWis(15.0)
info.SetIntel(20.0)
// Test individual stat getters (these include bonuses from spell effects)
str := entity.GetStr()
if str < 10 {
t.Errorf("Expected strength >= 10, got %d", str)
}
sta := entity.GetSta()
if sta < 12 {
t.Errorf("Expected stamina >= 12, got %d", sta)
}
agi := entity.GetAgi()
if agi < 8 {
t.Errorf("Expected agility >= 8, got %d", agi)
}
wis := entity.GetWis()
if wis < 15 {
t.Errorf("Expected wisdom >= 15, got %d", wis)
}
intel := entity.GetIntel()
if intel < 20 {
t.Errorf("Expected intelligence >= 20, got %d", intel)
}
// Test primary stat calculation
primaryStat := entity.GetPrimaryStat()
if primaryStat < 20 {
t.Errorf("Expected primary stat >= 20 (intelligence is highest), got %d", primaryStat)
}
}
func TestEntityResistances(t *testing.T) {
entity := NewEntity()
info := entity.GetInfoStruct()
// Set base resistances
info.SetResistance("heat", 10)
info.SetResistance("cold", 15)
info.SetResistance("magic", 20)
info.SetResistance("mental", 25)
info.SetResistance("divine", 30)
info.SetResistance("disease", 35)
info.SetResistance("poison", 40)
// Test resistance getters
if entity.GetHeatResistance() != 10 {
t.Errorf("Expected heat resistance 10, got %d", entity.GetHeatResistance())
}
if entity.GetColdResistance() != 15 {
t.Errorf("Expected cold resistance 15, got %d", entity.GetColdResistance())
}
if entity.GetMagicResistance() != 20 {
t.Errorf("Expected magic resistance 20, got %d", entity.GetMagicResistance())
}
if entity.GetMentalResistance() != 25 {
t.Errorf("Expected mental resistance 25, got %d", entity.GetMentalResistance())
}
if entity.GetDivineResistance() != 30 {
t.Errorf("Expected divine resistance 30, got %d", entity.GetDivineResistance())
}
if entity.GetDiseaseResistance() != 35 {
t.Errorf("Expected disease resistance 35, got %d", entity.GetDiseaseResistance())
}
if entity.GetPoisonResistance() != 40 {
t.Errorf("Expected poison resistance 40, got %d", entity.GetPoisonResistance())
}
}
func TestEntityMaintainedSpells(t *testing.T) {
entity := NewEntity()
info := entity.GetInfoStruct()
// Set max concentration
info.SetMaxConcentration(10)
// Test adding maintained spell
success := entity.AddMaintainedSpell("Test Spell", 123, 60.0, 3)
if !success {
t.Error("Expected AddMaintainedSpell to succeed")
}
// Check concentration usage
if info.GetCurConcentration() != 3 {
t.Errorf("Expected current concentration 3, got %d", info.GetCurConcentration())
}
// Test getting maintained spell
effect := entity.GetMaintainedSpell(123)
if effect == nil {
t.Error("Expected to find maintained spell effect")
}
// Test adding spell that would exceed concentration
success = entity.AddMaintainedSpell("Another Spell", 124, 60.0, 8)
if success {
t.Error("Expected AddMaintainedSpell to fail when concentration exceeded")
}
// Test removing maintained spell
success = entity.RemoveMaintainedSpell(123)
if !success {
t.Error("Expected RemoveMaintainedSpell to succeed")
}
// Check concentration was returned
if info.GetCurConcentration() != 0 {
t.Errorf("Expected current concentration 0, got %d", info.GetCurConcentration())
}
// Test removing non-existent spell
success = entity.RemoveMaintainedSpell(999)
if success {
t.Error("Expected RemoveMaintainedSpell to fail for non-existent spell")
}
}
func TestEntitySpellEffects(t *testing.T) {
entity := NewEntity()
// Test adding spell effect
success := entity.AddSpellEffect(456, 789, 30.0)
if !success {
t.Error("Expected AddSpellEffect to succeed")
}
// Test removing spell effect
success = entity.RemoveSpellEffect(456)
if !success {
t.Error("Expected RemoveSpellEffect to succeed")
}
// Test removing non-existent spell effect
success = entity.RemoveSpellEffect(999)
if success {
t.Error("Expected RemoveSpellEffect to fail for non-existent spell")
}
}
func TestEntityDetrimentalSpells(t *testing.T) {
entity := NewEntity()
// Test adding detrimental spell
entity.AddDetrimentalSpell(789, 456, 45.0, 1)
// Test getting detrimental effect
effect := entity.GetDetrimentalEffect(789, 456)
if effect == nil {
t.Error("Expected to find detrimental spell effect")
}
// Test removing detrimental spell
success := entity.RemoveDetrimentalSpell(789, 456)
if !success {
t.Error("Expected RemoveDetrimentalSpell to succeed")
}
// Test removing non-existent detrimental spell
success = entity.RemoveDetrimentalSpell(999, 888)
if success {
t.Error("Expected RemoveDetrimentalSpell to fail for non-existent spell")
}
}
func TestEntityBonusSystem(t *testing.T) {
entity := NewEntity()
// Test adding skill bonus
entity.AddSkillBonus(123, 5, 15.0) // Skill ID 5, value 15.0
// Test adding stat bonus
entity.AddStatBonus(124, 1, 5.0) // Stat type 1 (STR), value 5.0
// Test calculating bonuses
entity.CalculateBonuses()
// The actual bonus calculation depends on the spell effect manager implementation
// We just test that the method doesn't panic
}
func TestEntityPetManagement(t *testing.T) {
entity := NewEntity()
pet := NewEntity()
// Test setting and getting summon pet
entity.SetPet(pet)
if entity.GetPet() != pet {
t.Error("Expected pet to be set correctly")
}
if pet.GetOwner() != entity.GetID() {
t.Error("Expected pet owner to be set correctly")
}
if pet.GetPetType() != PetTypeSummon {
t.Error("Expected pet type to be summon")
}
// Test charmed pet
charmedPet := NewEntity()
entity.SetCharmedPet(charmedPet)
if entity.GetCharmedPet() != charmedPet {
t.Error("Expected charmed pet to be set correctly")
}
if charmedPet.GetPetType() != PetTypeCharm {
t.Error("Expected pet type to be charm")
}
// Test deity pet
deityPet := NewEntity()
entity.SetDeityPet(deityPet)
if entity.GetDeityPet() != deityPet {
t.Error("Expected deity pet to be set correctly")
}
if deityPet.GetPetType() != PetTypeDeity {
t.Error("Expected pet type to be deity")
}
// Test cosmetic pet
cosmeticPet := NewEntity()
entity.SetCosmeticPet(cosmeticPet)
if entity.GetCosmeticPet() != cosmeticPet {
t.Error("Expected cosmetic pet to be set correctly")
}
if cosmeticPet.GetPetType() != PetTypeCosmetic {
t.Error("Expected pet type to be cosmetic")
}
// Test pet dismissing state
entity.SetPetDismissing(true)
if !entity.IsPetDismissing() {
t.Error("Expected pet to be dismissing")
}
entity.SetPetDismissing(false)
if entity.IsPetDismissing() {
t.Error("Expected pet to not be dismissing")
}
}
func TestEntityDeity(t *testing.T) {
entity := NewEntity()
// Test setting and getting deity
entity.SetDeity(5)
if entity.GetDeity() != 5 {
t.Errorf("Expected deity 5, got %d", entity.GetDeity())
}
}
func TestEntityDodgeChance(t *testing.T) {
entity := NewEntity()
// Test base dodge chance
dodgeChance := entity.GetDodgeChance()
if dodgeChance != 5.0 {
t.Errorf("Expected base dodge chance 5.0, got %f", dodgeChance)
}
}
func TestEntitySeeSpells(t *testing.T) {
entity := NewEntity()
// Test see invisible spell
entity.SetSeeInvisSpell(true)
if !entity.HasSeeInvisSpell() {
t.Error("Expected entity to have see invisible spell")
}
entity.SetSeeInvisSpell(false)
if entity.HasSeeInvisSpell() {
t.Error("Expected entity to not have see invisible spell")
}
// Test see hidden spell
entity.SetSeeHideSpell(true)
if !entity.HasSeeHideSpell() {
t.Error("Expected entity to have see hidden spell")
}
entity.SetSeeHideSpell(false)
if entity.HasSeeHideSpell() {
t.Error("Expected entity to not have see hidden spell")
}
}
func TestEntityCleanupMethods(t *testing.T) {
entity := NewEntity()
info := entity.GetInfoStruct()
// Set up some spell effects and concentration usage
info.SetMaxConcentration(10)
entity.AddMaintainedSpell("Test Spell 1", 123, 60.0, 3)
entity.AddMaintainedSpell("Test Spell 2", 124, 60.0, 2)
entity.AddSpellEffect(456, 789, 30.0)
// Verify concentration is used
if info.GetCurConcentration() != 5 {
t.Errorf("Expected current concentration 5, got %d", info.GetCurConcentration())
}
// Test deleting all spell effects
entity.DeleteSpellEffects(false)
// Verify concentration was returned
if info.GetCurConcentration() != 0 {
t.Errorf("Expected current concentration 0 after cleanup, got %d", info.GetCurConcentration())
}
// Test RemoveSpells
entity.AddMaintainedSpell("Test Spell 3", 125, 60.0, 2)
entity.RemoveSpells(false) // Remove all spells
if info.GetCurConcentration() != 0 {
t.Errorf("Expected current concentration 0 after RemoveSpells, got %d", info.GetCurConcentration())
}
}
func TestEntityProcessEffects(t *testing.T) {
entity := NewEntity()
// Test that ProcessEffects doesn't panic
entity.ProcessEffects()
}
func TestEntityClassSystemIntegration(t *testing.T) {
entity := NewEntity()
info := entity.GetInfoStruct()
// Test class getter/setter
info.SetClass1(5)
if entity.GetClass() != 5 {
t.Errorf("Expected class 5, got %d", entity.GetClass())
}
entity.SetClass(10)
if entity.GetClass() != 10 {
t.Errorf("Expected class 10, got %d", entity.GetClass())
}
// Test level getter
info.SetLevel(25)
if entity.GetLevel() != 25 {
t.Errorf("Expected level 25, got %d", entity.GetLevel())
}
}
func TestEntityConcurrency(t *testing.T) {
entity := NewEntity()
info := entity.GetInfoStruct()
info.SetMaxConcentration(20)
var wg sync.WaitGroup
numGoroutines := 10
// Test concurrent access to combat state
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
entity.SetInCombat(true)
_ = entity.IsInCombat()
entity.SetInCombat(false)
}()
}
wg.Wait()
// Test concurrent access to casting state
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
entity.SetCasting(true)
_ = entity.IsCasting()
entity.SetCasting(false)
}()
}
wg.Wait()
// Test concurrent spell effect operations
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
spellID := int32(1000 + i)
go func(id int32) {
defer wg.Done()
entity.AddSpellEffect(id, 123, 30.0)
time.Sleep(time.Millisecond) // Small delay to ensure some overlap
entity.RemoveSpellEffect(id)
}(spellID)
}
wg.Wait()
// Test concurrent maintained spell operations
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
spellID := int32(2000 + i)
go func(id int32) {
defer wg.Done()
if entity.AddMaintainedSpell("Concurrent Spell", id, 60.0, 1) {
time.Sleep(time.Millisecond)
entity.RemoveMaintainedSpell(id)
}
}(spellID)
}
wg.Wait()
}
func TestEntityControlEffects(t *testing.T) {
entity := NewEntity()
// Test has control effect - should work without panicking
// The actual implementation depends on the spell effect manager
hasStun := entity.HasControlEffect(spells.ControlEffectStun)
_ = hasStun // We can't easily test the actual value without setting up effects
}
func TestEntityConstants(t *testing.T) {
// Test pet type constants
if PetTypeSummon != 1 {
t.Errorf("Expected PetTypeSummon to be 1, got %d", PetTypeSummon)
}
if PetTypeCharm != 2 {
t.Errorf("Expected PetTypeCharm to be 2, got %d", PetTypeCharm)
}
if PetTypeDeity != 3 {
t.Errorf("Expected PetTypeDeity to be 3, got %d", PetTypeDeity)
}
if PetTypeCosmetic != 4 {
t.Errorf("Expected PetTypeCosmetic to be 4, got %d", PetTypeCosmetic)
}
// Test control effect constants (re-exported from spells package)
if spells.ControlEffectStun == 0 && spells.ControlEffectRoot == 0 {
t.Error("Control effect constants should be non-zero")
}
}

View File

@ -1,514 +0,0 @@
package entity
import (
"sync"
"testing"
"time"
)
func TestNewInfoStruct(t *testing.T) {
info := NewInfoStruct()
if info == nil {
t.Fatal("NewInfoStruct returned nil")
}
// Test default values
if info.GetName() != "" {
t.Errorf("Expected empty name, got %s", info.GetName())
}
if info.GetLevel() != 0 {
t.Errorf("Expected level 0, got %d", info.GetLevel())
}
if info.GetMaxConcentration() != 5 {
t.Errorf("Expected max concentration 5, got %d", info.GetMaxConcentration())
}
if info.GetCurConcentration() != 0 {
t.Errorf("Expected current concentration 0, got %d", info.GetCurConcentration())
}
}
func TestInfoStructBasicProperties(t *testing.T) {
info := NewInfoStruct()
// Test name
info.SetName("Test Character")
if info.GetName() != "Test Character" {
t.Errorf("Expected name 'Test Character', got %s", info.GetName())
}
// Test level
info.SetLevel(25)
if info.GetLevel() != 25 {
t.Errorf("Expected level 25, got %d", info.GetLevel())
}
// Test effective level
info.SetEffectiveLevel(30)
if info.GetEffectiveLevel() != 30 {
t.Errorf("Expected effective level 30, got %d", info.GetEffectiveLevel())
}
// Test class
info.SetClass1(5)
if info.GetClass1() != 5 {
t.Errorf("Expected class 5, got %d", info.GetClass1())
}
// Test race
info.SetRace(3)
if info.GetRace() != 3 {
t.Errorf("Expected race 3, got %d", info.GetRace())
}
// Test gender
info.SetGender(1)
if info.GetGender() != 1 {
t.Errorf("Expected gender 1, got %d", info.GetGender())
}
}
func TestInfoStructStats(t *testing.T) {
info := NewInfoStruct()
// Test strength
info.SetStr(15.5)
if info.GetStr() != 15.5 {
t.Errorf("Expected strength 15.5, got %f", info.GetStr())
}
// Test stamina
info.SetSta(20.0)
if info.GetSta() != 20.0 {
t.Errorf("Expected stamina 20.0, got %f", info.GetSta())
}
// Test agility
info.SetAgi(12.75)
if info.GetAgi() != 12.75 {
t.Errorf("Expected agility 12.75, got %f", info.GetAgi())
}
// Test wisdom
info.SetWis(18.25)
if info.GetWis() != 18.25 {
t.Errorf("Expected wisdom 18.25, got %f", info.GetWis())
}
// Test intelligence
info.SetIntel(22.5)
if info.GetIntel() != 22.5 {
t.Errorf("Expected intelligence 22.5, got %f", info.GetIntel())
}
}
func TestInfoStructConcentration(t *testing.T) {
info := NewInfoStruct()
// Test setting max concentration
info.SetMaxConcentration(15)
if info.GetMaxConcentration() != 15 {
t.Errorf("Expected max concentration 15, got %d", info.GetMaxConcentration())
}
// Test adding concentration
success := info.AddConcentration(5)
if !success {
t.Error("Expected AddConcentration to succeed")
}
if info.GetCurConcentration() != 5 {
t.Errorf("Expected current concentration 5, got %d", info.GetCurConcentration())
}
// Test adding concentration that would exceed maximum
success = info.AddConcentration(12)
if success {
t.Error("Expected AddConcentration to fail when exceeding maximum")
}
if info.GetCurConcentration() != 5 {
t.Errorf("Expected current concentration to remain 5, got %d", info.GetCurConcentration())
}
// Test adding concentration that exactly reaches maximum
success = info.AddConcentration(10)
if !success {
t.Error("Expected AddConcentration to succeed when exactly reaching maximum")
}
if info.GetCurConcentration() != 15 {
t.Errorf("Expected current concentration 15, got %d", info.GetCurConcentration())
}
// Test removing concentration
info.RemoveConcentration(7)
if info.GetCurConcentration() != 8 {
t.Errorf("Expected current concentration 8, got %d", info.GetCurConcentration())
}
// Test removing more concentration than available
info.RemoveConcentration(20)
if info.GetCurConcentration() != 0 {
t.Errorf("Expected current concentration to be clamped to 0, got %d", info.GetCurConcentration())
}
}
func TestInfoStructCoins(t *testing.T) {
info := NewInfoStruct()
// Test initial coins
if info.GetCoins() != 0 {
t.Errorf("Expected initial coins 0, got %d", info.GetCoins())
}
// Test adding copper
info.AddCoins(150) // 1 silver and 50 copper
totalCopper := info.GetCoins()
if totalCopper != 150 {
t.Errorf("Expected total coins 150, got %d", totalCopper)
}
// Test adding large amount that converts to higher denominations
info.AddCoins(1234567) // Should convert to plat, gold, silver, copper
expectedTotal := 150 + 1234567
totalCopper = info.GetCoins()
if totalCopper != int32(expectedTotal) {
t.Errorf("Expected total coins %d, got %d", expectedTotal, totalCopper)
}
// Test removing coins
success := info.RemoveCoins(100)
if !success {
t.Error("Expected RemoveCoins to succeed")
}
expectedTotal -= 100
totalCopper = info.GetCoins()
if totalCopper != int32(expectedTotal) {
t.Errorf("Expected total coins %d after removal, got %d", expectedTotal, totalCopper)
}
// Test removing more coins than available
success = info.RemoveCoins(9999999)
if success {
t.Error("Expected RemoveCoins to fail when removing more than available")
}
// Total should remain unchanged
totalCopper = info.GetCoins()
if totalCopper != int32(expectedTotal) {
t.Errorf("Expected total coins to remain %d, got %d", expectedTotal, totalCopper)
}
}
func TestInfoStructResistances(t *testing.T) {
info := NewInfoStruct()
// Test all resistance types
resistanceTypes := []string{"heat", "cold", "magic", "mental", "divine", "disease", "poison"}
expectedValues := []int16{10, 15, 20, 25, 30, 35, 40}
for i, resistType := range resistanceTypes {
expectedValue := expectedValues[i]
// Set resistance
info.SetResistance(resistType, expectedValue)
// Get resistance
actualValue := info.GetResistance(resistType)
if actualValue != expectedValue {
t.Errorf("Expected %s resistance %d, got %d", resistType, expectedValue, actualValue)
}
}
// Test invalid resistance type
unknownResist := info.GetResistance("unknown")
if unknownResist != 0 {
t.Errorf("Expected unknown resistance type to return 0, got %d", unknownResist)
}
// Setting invalid resistance type should not panic
info.SetResistance("unknown", 50) // Should be ignored
}
func TestInfoStructResetEffects(t *testing.T) {
info := NewInfoStruct()
// Set some base values first (these would normally be set during character creation)
info.str = 10.0
info.strBase = 10.0
info.sta = 12.0
info.staBase = 12.0
info.heat = 15
info.heatBase = 15
// Modify current values to simulate bonuses
info.SetStr(20.0)
info.SetSta(25.0)
info.SetResistance("heat", 30)
// Reset effects
info.ResetEffects()
// Check that values were reset to base
if info.GetStr() != 10.0 {
t.Errorf("Expected strength to reset to 10.0, got %f", info.GetStr())
}
if info.GetSta() != 12.0 {
t.Errorf("Expected stamina to reset to 12.0, got %f", info.GetSta())
}
if info.GetResistance("heat") != 15 {
t.Errorf("Expected heat resistance to reset to 15, got %d", info.GetResistance("heat"))
}
}
func TestInfoStructCalculatePrimaryStat(t *testing.T) {
info := NewInfoStruct()
// Set all stats to different values
info.SetStr(10.0)
info.SetSta(15.0)
info.SetAgi(8.0)
info.SetWis(20.0) // This should be the highest
info.SetIntel(12.0)
primaryStat := info.CalculatePrimaryStat()
if primaryStat != 20.0 {
t.Errorf("Expected primary stat 20.0 (wisdom), got %f", primaryStat)
}
// Test with intelligence being highest
info.SetIntel(25.0)
primaryStat = info.CalculatePrimaryStat()
if primaryStat != 25.0 {
t.Errorf("Expected primary stat 25.0 (intelligence), got %f", primaryStat)
}
// Test with all stats equal
info.SetStr(30.0)
info.SetSta(30.0)
info.SetAgi(30.0)
info.SetWis(30.0)
info.SetIntel(30.0)
primaryStat = info.CalculatePrimaryStat()
if primaryStat != 30.0 {
t.Errorf("Expected primary stat 30.0 (all equal), got %f", primaryStat)
}
}
func TestInfoStructClone(t *testing.T) {
info := NewInfoStruct()
// Set some values
info.SetName("Original Character")
info.SetLevel(15)
info.SetStr(20.0)
info.SetResistance("magic", 25)
info.AddConcentration(3)
// Clone the info struct
clone := info.Clone()
if clone == nil {
t.Fatal("Clone returned nil")
}
// Verify clone has same values
if clone.GetName() != "Original Character" {
t.Errorf("Expected cloned name 'Original Character', got %s", clone.GetName())
}
if clone.GetLevel() != 15 {
t.Errorf("Expected cloned level 15, got %d", clone.GetLevel())
}
if clone.GetStr() != 20.0 {
t.Errorf("Expected cloned strength 20.0, got %f", clone.GetStr())
}
if clone.GetResistance("magic") != 25 {
t.Errorf("Expected cloned magic resistance 25, got %d", clone.GetResistance("magic"))
}
if clone.GetCurConcentration() != 3 {
t.Errorf("Expected cloned concentration 3, got %d", clone.GetCurConcentration())
}
// Verify that modifying the clone doesn't affect the original
clone.SetName("Cloned Character")
clone.SetLevel(20)
if info.GetName() != "Original Character" {
t.Error("Original name was modified when clone was changed")
}
if info.GetLevel() != 15 {
t.Error("Original level was modified when clone was changed")
}
}
func TestInfoStructGetUptime(t *testing.T) {
info := NewInfoStruct()
// Test uptime (currently returns 0 as it's not implemented)
uptime := info.GetUptime()
if uptime != time.Duration(0) {
t.Errorf("Expected uptime to be 0 (not implemented), got %v", uptime)
}
}
func TestInfoStructConcurrency(t *testing.T) {
info := NewInfoStruct()
info.SetMaxConcentration(100)
var wg sync.WaitGroup
numGoroutines := 20
// Test concurrent access to basic properties
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(index int) {
defer wg.Done()
// Each goroutine sets unique values
info.SetName("Character" + string(rune('A'+index)))
_ = info.GetName()
info.SetLevel(int16(10 + index))
_ = info.GetLevel()
info.SetStr(float32(10.0 + float32(index)))
_ = info.GetStr()
}(i)
}
wg.Wait()
// Test concurrent concentration operations
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
// Try to add concentration
if info.AddConcentration(1) {
// If successful, remove it after a short delay
time.Sleep(time.Microsecond)
info.RemoveConcentration(1)
}
}()
}
wg.Wait()
// After all operations, concentration should be back to 0
// (This might not always be true due to race conditions, but shouldn't crash)
_ = info.GetCurConcentration()
// Test concurrent coin operations
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(amount int32) {
defer wg.Done()
info.AddCoins(amount)
_ = info.GetCoins()
// Try to remove some coins
info.RemoveCoins(amount / 2)
}(int32(100 + i))
}
wg.Wait()
// Test concurrent resistance operations
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(value int16) {
defer wg.Done()
info.SetResistance("heat", value)
_ = info.GetResistance("heat")
info.SetResistance("cold", value+1)
_ = info.GetResistance("cold")
}(int16(i))
}
wg.Wait()
}
func TestInfoStructLargeValues(t *testing.T) {
info := NewInfoStruct()
// Test with large coin amounts
largeCoinAmount := int32(2000000000) // 2 billion copper
info.AddCoins(largeCoinAmount)
totalCoins := info.GetCoins()
if totalCoins != largeCoinAmount {
t.Errorf("Expected large coin amount %d, got %d", largeCoinAmount, totalCoins)
}
// Test removing large amounts
success := info.RemoveCoins(largeCoinAmount)
if !success {
t.Error("Expected to be able to remove large coin amount")
}
if info.GetCoins() != 0 {
t.Errorf("Expected coins to be 0 after removing all, got %d", info.GetCoins())
}
// Test with maximum values
info.SetMaxConcentration(32767) // Max int16
info.SetLevel(32767)
if info.GetMaxConcentration() != 32767 {
t.Errorf("Expected max concentration 32767, got %d", info.GetMaxConcentration())
}
if info.GetLevel() != 32767 {
t.Errorf("Expected level 32767, got %d", info.GetLevel())
}
}
func TestInfoStructEdgeCases(t *testing.T) {
info := NewInfoStruct()
// Test negative values
info.SetStr(-5.0)
if info.GetStr() != -5.0 {
t.Errorf("Expected negative strength -5.0, got %f", info.GetStr())
}
// Test zero values
info.SetMaxConcentration(0)
success := info.AddConcentration(1)
if success {
t.Error("Expected AddConcentration to fail with max concentration 0")
}
// Test very small float values
info.SetAgi(0.001)
if info.GetAgi() != 0.001 {
t.Errorf("Expected small agility 0.001, got %f", info.GetAgi())
}
// Test empty string name
info.SetName("")
if info.GetName() != "" {
t.Errorf("Expected empty name, got '%s'", info.GetName())
}
// Test very long name
longName := string(make([]byte, 1000))
for i := range longName {
longName = longName[:i] + "A" + longName[i+1:]
}
info.SetName(longName)
if info.GetName() != longName {
t.Error("Expected to handle very long names")
}
}

View File

@ -1,182 +0,0 @@
# EQ2Go Event System
A simplified event-driven system for handling game logic without the complexity of a full scripting engine.
## Overview
The event system provides:
- Simple event registration and execution
- Context-based parameter passing
- 100+ built-in EQ2 game functions organized by domain
- Thread-safe operations
- Minimal overhead
- Domain-specific function organization
## Basic Usage
```go
// Create event handler
handler := NewEventHandler()
// Register all EQ2 functions (100+ functions organized by domain)
err := functions.RegisterAllEQ2Functions(handler)
// Create context and execute event
ctx := NewEventContext(EventTypeSpawn, "SetCurrentHP", "heal_spell").
WithSpawn(player).
WithParameter("hp", 150.0)
err = handler.Execute(ctx)
```
## Event Context
The `EventContext` provides:
- Game objects: `Caster`, `Target`, `Spawn`, `Quest`
- Parameters: Type-safe parameter access
- Results: Return values from event functions
- Logging: Built-in debug/info/warn/error logging
### Fluent API
```go
ctx := NewEventContext(EventTypeSpell, "heal", "cast").
WithCaster(caster).
WithTarget(target).
WithParameter("spell_id", 123).
WithParameter("power_cost", 50)
```
### Parameter Access
```go
func MyEvent(ctx *EventContext) error {
spellID := ctx.GetParameterInt("spell_id", 0)
message := ctx.GetParameterString("message", "default")
amount := ctx.GetParameterFloat("amount", 0.0)
enabled := ctx.GetParameterBool("enabled", false)
// Set results
ctx.SetResult("damage_dealt", 150)
return nil
}
```
## Available EQ2 Functions
The system provides 100+ functions organized by domain:
### Health Domain (23 functions)
- **HP Management**: `SetCurrentHP`, `SetMaxHP`, `SetMaxHPBase`, `GetCurrentHP`, `GetMaxHP`, `GetMaxHPBase`
- **Power Management**: `SetCurrentPower`, `SetMaxPower`, `SetMaxPowerBase`, `GetCurrentPower`, `GetMaxPower`, `GetMaxPowerBase`
- **Modifiers**: `ModifyHP`, `ModifyPower`, `ModifyMaxHP`, `ModifyMaxPower`, `ModifyTotalHP`, `ModifyTotalPower`
- **Percentages**: `GetPCTOfHP`, `GetPCTOfPower`
- **Healing**: `SpellHeal`, `SpellHealPct`
- **State**: `IsAlive`
### Attributes Domain (24 functions)
- **Stats**: `SetInt`, `SetWis`, `SetSta`, `SetStr`, `SetAgi`, `GetInt`, `GetWis`, `GetSta`, `GetStr`, `GetAgi`
- **Base Stats**: `SetIntBase`, `SetWisBase`, `SetStaBase`, `SetStrBase`, `SetAgiBase`, `GetIntBase`, `GetWisBase`, `GetStaBase`, `GetStrBase`, `GetAgiBase`
- **Character Info**: `GetLevel`, `SetLevel`, `SetPlayerLevel`, `GetDifficulty`, `GetClass`, `SetClass`, `SetAdventureClass`
- **Classes**: `GetTradeskillClass`, `SetTradeskillClass`, `GetTradeskillLevel`, `SetTradeskillLevel`
- **Identity**: `GetRace`, `GetGender`, `GetModelType`, `SetModelType`, `GetDeity`, `SetDeity`, `GetAlignment`, `SetAlignment`
- **Bonuses**: `AddSpellBonus`, `RemoveSpellBonus`, `AddSkillBonus`, `RemoveSkillBonus`
### Movement Domain (27 functions)
- **Position**: `SetPosition`, `GetPosition`, `GetX`, `GetY`, `GetZ`, `GetHeading`, `SetHeading`
- **Original Position**: `GetOrigX`, `GetOrigY`, `GetOrigZ`
- **Distance & Facing**: `GetDistance`, `FaceTarget`
- **Speed**: `GetSpeed`, `SetSpeed`, `SetSpeedMultiplier`, `HasMoved`, `IsRunning`
- **Movement**: `MoveToLocation`, `ClearRunningLocations`, `SpawnMove`, `MovementLoopAdd`, `PauseMovement`, `StopMovement`
- **Mounts**: `SetMount`, `GetMount`, `SetMountColor`, `StartAutoMount`, `EndAutoMount`, `IsOnAutoMount`
- **Waypoints**: `AddWaypoint`, `RemoveWaypoint`, `SendWaypoints`
- **Transport**: `Evac`, `Bind`, `Gate`
### Combat Domain (36 functions)
- **Basic Combat**: `Attack`, `AddHate`, `ClearHate`, `GetMostHated`, `SetTarget`, `GetTarget`
- **Combat State**: `IsInCombat`, `SetInCombat`, `IsCasting`, `HasRecovered`
- **Damage**: `SpellDamage`, `SpellDamageExt`, `DamageSpawn`, `ProcDamage`, `ProcHate`
- **Effects**: `Knockback`, `Interrupt`
- **Processing**: `ProcessMelee`, `ProcessSpell`, `LastSpellAttackHit`
- **Positioning**: `IsBehind`, `IsFlanking`, `InFront`
- **Encounters**: `GetEncounterSize`, `GetEncounter`, `GetHateList`, `ClearEncounter`
- **AI**: `ClearRunback`, `Runback`, `GetRunbackDistance`, `CompareSpawns`
- **Life/Death**: `KillSpawn`, `KillSpawnByDistance`, `Resurrect`
- **Invulnerability**: `IsInvulnerable`, `SetInvulnerable`, `SetAttackable`
### Miscellaneous Domain (27 functions)
- **Messaging**: `SendMessage`, `LogMessage`
- **Utility**: `MakeRandomInt`, `MakeRandomFloat`, `ParseInt`
- **Identity**: `GetName`, `GetID`, `GetSpawnID`, `IsPlayer`, `IsNPC`, `IsEntity`, `IsDead`, `GetCharacterID`
- **Spawning**: `Despawn`, `Spawn`, `SpawnByLocationID`, `SpawnGroupByID`, `DespawnByLocationID`
- **Groups**: `GetSpawnByLocationID`, `GetSpawnByGroupID`, `GetSpawnGroupID`, `SetSpawnGroupID`, `AddSpawnToGroup`, `IsSpawnGroupAlive`
- **Location**: `GetSpawnLocationID`, `GetSpawnLocationPlacementID`, `SetGridID`
- **Spawn Management**: `SpawnSet`, `SpawnSetByDistance`
- **Variables**: `GetVariableValue`, `SetServerVariable`, `GetServerVariable`, `SetTempVariable`, `GetTempVariable`
- **Line of Sight**: `CheckLOS`, `CheckLOSByCoordinates`
## Function Organization
Access functions by domain using the `functions` package:
```go
import "eq2emu/internal/events/functions"
// Register all functions at once
handler := events.NewEventHandler()
err := functions.RegisterAllEQ2Functions(handler)
// Get functions organized by domain
domains := functions.GetFunctionsByDomain()
healthFunctions := domains["health"] // 23 functions
combatFunctions := domains["combat"] // 36 functions
movementFunctions := domains["movement"] // 27 functions
// ... etc
```
## Custom Events
```go
// Register custom event
handler.Register("my_custom_event", func(ctx *events.EventContext) error {
spawn := ctx.GetSpawn()
if spawn == nil {
return fmt.Errorf("no spawn provided")
}
// Custom logic here
ctx.Debug("Custom event executed for %s", spawn.GetName())
return nil
})
// Execute custom event
ctx := events.NewEventContext(events.EventTypeSpawn, "my_custom_event", "trigger").
WithSpawn(someSpawn)
err := handler.Execute(ctx)
```
## Event Types
- `EventTypeSpell` - Spell-related events
- `EventTypeSpawn` - Spawn-related events
- `EventTypeQuest` - Quest-related events
- `EventTypeCombat` - Combat-related events
- `EventTypeZone` - Zone-related events
- `EventTypeItem` - Item-related events
## Thread Safety
All operations are thread-safe:
- Event registration/unregistration
- Context parameter/result access
- Event execution
## Performance
The event system is designed for minimal overhead:
- No complex registry or statistics
- Direct function calls
- Simple context passing
- Optional timeout support

View File

@ -1,254 +0,0 @@
package functions
import (
"testing"
"eq2emu/internal/events"
"eq2emu/internal/spawn"
)
func TestAllEQ2Functions(t *testing.T) {
// Create event handler
handler := events.NewEventHandler()
// Register all EQ2 functions
err := RegisterAllEQ2Functions(handler)
if err != nil {
t.Fatalf("Failed to register all EQ2 functions: %v", err)
}
// Verify we have a substantial number of functions registered
events := handler.ListEvents()
if len(events) < 100 {
t.Errorf("Expected at least 100 functions, got %d", len(events))
}
// Test some key functions exist
requiredFunctions := []string{
"SetCurrentHP", "GetCurrentHP", "SetMaxHP", "GetMaxHP",
"SetLevel", "GetLevel", "SetClass", "GetClass",
"SetPosition", "GetPosition", "GetX", "GetY", "GetZ",
"Attack", "AddHate", "SpellDamage", "IsInCombat",
"GetName", "GetID", "IsPlayer", "IsNPC",
"MakeRandomInt", "ParseInt", "LogMessage",
}
for _, funcName := range requiredFunctions {
if !handler.HasEvent(funcName) {
t.Errorf("Required function %s not registered", funcName)
}
}
}
func TestHealthFunctions(t *testing.T) {
handler := events.NewEventHandler()
err := RegisterAllEQ2Functions(handler)
if err != nil {
t.Fatalf("Failed to register functions: %v", err)
}
// Create test spawn
testSpawn := &spawn.Spawn{}
// Test SetCurrentHP
ctx := events.NewEventContext(events.EventTypeSpawn, "SetCurrentHP", "test").
WithSpawn(testSpawn).
WithParameter("hp", 250.0)
err = handler.Execute(ctx)
if err != nil {
t.Fatalf("SetCurrentHP failed: %v", err)
}
// Test GetCurrentHP
ctx2 := events.NewEventContext(events.EventTypeSpawn, "GetCurrentHP", "test").
WithSpawn(testSpawn)
err = handler.Execute(ctx2)
if err != nil {
t.Fatalf("GetCurrentHP failed: %v", err)
}
// Verify result
if hp, exists := ctx2.GetResult("hp"); !exists {
t.Error("GetCurrentHP should return hp result")
} else if hp != int32(250) {
t.Errorf("Expected HP 250, got %v", hp)
}
}
func TestAttributeFunctions(t *testing.T) {
handler := events.NewEventHandler()
err := RegisterAllEQ2Functions(handler)
if err != nil {
t.Fatalf("Failed to register functions: %v", err)
}
// Create test spawn
testSpawn := &spawn.Spawn{}
// Test SetLevel
ctx := events.NewEventContext(events.EventTypeSpawn, "SetLevel", "test").
WithSpawn(testSpawn).
WithParameter("level", 50)
err = handler.Execute(ctx)
if err != nil {
t.Fatalf("SetLevel failed: %v", err)
}
// Test GetLevel
ctx2 := events.NewEventContext(events.EventTypeSpawn, "GetLevel", "test").
WithSpawn(testSpawn)
err = handler.Execute(ctx2)
if err != nil {
t.Fatalf("GetLevel failed: %v", err)
}
// Verify result
if level, exists := ctx2.GetResult("level"); !exists {
t.Error("GetLevel should return level result")
} else if level != int16(50) {
t.Errorf("Expected level 50, got %v", level)
}
}
func TestMovementFunctions(t *testing.T) {
handler := events.NewEventHandler()
err := RegisterAllEQ2Functions(handler)
if err != nil {
t.Fatalf("Failed to register functions: %v", err)
}
// Create test spawn
testSpawn := &spawn.Spawn{}
// Test SetPosition
ctx := events.NewEventContext(events.EventTypeSpawn, "SetPosition", "test").
WithSpawn(testSpawn).
WithParameter("x", 100.0).
WithParameter("y", 200.0).
WithParameter("z", 300.0).
WithParameter("heading", 180.0)
err = handler.Execute(ctx)
if err != nil {
t.Fatalf("SetPosition failed: %v", err)
}
// Test GetPosition
ctx2 := events.NewEventContext(events.EventTypeSpawn, "GetPosition", "test").
WithSpawn(testSpawn)
err = handler.Execute(ctx2)
if err != nil {
t.Fatalf("GetPosition failed: %v", err)
}
// Verify results
if x, exists := ctx2.GetResult("x"); !exists || x != float32(100.0) {
t.Errorf("Expected X=100.0, got %v (exists: %t)", x, exists)
}
if y, exists := ctx2.GetResult("y"); !exists || y != float32(200.0) {
t.Errorf("Expected Y=200.0, got %v (exists: %t)", y, exists)
}
if z, exists := ctx2.GetResult("z"); !exists || z != float32(300.0) {
t.Errorf("Expected Z=300.0, got %v (exists: %t)", z, exists)
}
}
func TestMiscFunctions(t *testing.T) {
handler := events.NewEventHandler()
err := RegisterAllEQ2Functions(handler)
if err != nil {
t.Fatalf("Failed to register functions: %v", err)
}
// Create test spawn
testSpawn := &spawn.Spawn{}
// Test GetName
ctx := events.NewEventContext(events.EventTypeSpawn, "GetName", "test").
WithSpawn(testSpawn)
err = handler.Execute(ctx)
if err != nil {
t.Fatalf("GetName failed: %v", err)
}
// Test MakeRandomInt
ctx2 := events.NewEventContext(events.EventTypeSpawn, "MakeRandomInt", "test").
WithParameter("min", 10).
WithParameter("max", 20)
err = handler.Execute(ctx2)
if err != nil {
t.Fatalf("MakeRandomInt failed: %v", err)
}
if result, exists := ctx2.GetResult("random_int"); !exists {
t.Error("MakeRandomInt should return random_int result")
} else if randInt, ok := result.(int); !ok || randInt < 10 || randInt > 20 {
t.Errorf("Expected random int between 10-20, got %v", result)
}
}
func TestFunctionsByDomain(t *testing.T) {
domains := GetFunctionsByDomain()
// Verify we have expected domains
expectedDomains := []string{"health", "attributes", "movement", "combat", "misc"}
for _, domain := range expectedDomains {
if functions, exists := domains[domain]; !exists {
t.Errorf("Domain %s not found", domain)
} else if len(functions) == 0 {
t.Errorf("Domain %s has no functions", domain)
}
}
// Verify health domain has expected functions
healthFunctions := domains["health"]
expectedHealthFunctions := []string{"SetCurrentHP", "GetCurrentHP", "SetMaxHP", "GetMaxHP"}
for _, funcName := range expectedHealthFunctions {
found := false
for _, f := range healthFunctions {
if f == funcName {
found = true
break
}
}
if !found {
t.Errorf("Health domain missing function %s", funcName)
}
}
}
func TestErrorHandling(t *testing.T) {
handler := events.NewEventHandler()
err := RegisterAllEQ2Functions(handler)
if err != nil {
t.Fatalf("Failed to register functions: %v", err)
}
// Test function with no spawn context
ctx := events.NewEventContext(events.EventTypeSpawn, "SetCurrentHP", "test").
WithParameter("hp", 100.0)
// No spawn set
err = handler.Execute(ctx)
if err == nil {
t.Error("SetCurrentHP should fail without spawn context")
}
// Test function with invalid parameters
testSpawn := &spawn.Spawn{}
ctx2 := events.NewEventContext(events.EventTypeSpawn, "SetCurrentHP", "test").
WithSpawn(testSpawn).
WithParameter("hp", -50.0) // Negative HP
err = handler.Execute(ctx2)
if err == nil {
t.Error("SetCurrentHP should fail with negative HP")
}
}

View File

@ -1,836 +0,0 @@
package factions
import (
"fmt"
"testing"
)
// Benchmark MasterFactionList operations
func BenchmarkMasterFactionList(b *testing.B) {
b.Run("GetFaction", func(b *testing.B) {
mfl := NewMasterList()
// Pre-populate with factions
for i := int32(1); i <= 1000; i++ {
faction := NewFaction(i, "Benchmark Faction", "Test", "Benchmark test")
mfl.AddFaction(faction)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 1000) + 1)
_ = mfl.GetFaction(factionID)
}
})
b.Run("GetFactionByName", func(b *testing.B) {
mfl := NewMasterList()
// Pre-populate with factions
for i := int32(1); i <= 1000; i++ {
faction := NewFaction(i, "Benchmark Faction", "Test", "Benchmark test")
mfl.AddFaction(faction)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = mfl.GetFactionByName("Benchmark Faction")
}
})
b.Run("AddFaction", func(b *testing.B) {
mfl := NewMasterList()
// Pre-populate with factions
for i := int32(1); i <= 1000; i++ {
faction := NewFaction(i, "Benchmark Faction", "Test", "Benchmark test")
mfl.AddFaction(faction)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32(2000 + i)
faction := NewFaction(factionID, "New Faction", "Test", "New test")
mfl.AddFaction(faction)
}
})
b.Run("GetFactionCount", func(b *testing.B) {
mfl := NewMasterList()
// Pre-populate with factions
for i := int32(1); i <= 1000; i++ {
faction := NewFaction(i, "Benchmark Faction", "Test", "Benchmark test")
mfl.AddFaction(faction)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = mfl.GetFactionCount()
}
})
b.Run("AddHostileFaction", func(b *testing.B) {
mfl := NewMasterList()
// Pre-populate with factions
for i := int32(1); i <= 1000; i++ {
faction := NewFaction(i, "Benchmark Faction", "Test", "Benchmark test")
mfl.AddFaction(faction)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 1000) + 1)
hostileID := int32(((i + 1) % 1000) + 1)
mfl.AddHostileFaction(factionID, hostileID)
}
})
b.Run("GetHostileFactions", func(b *testing.B) {
mfl := NewMasterList()
// Pre-populate with factions and some hostile relationships
for i := int32(1); i <= 1000; i++ {
faction := NewFaction(i, "Benchmark Faction", "Test", "Benchmark test")
mfl.AddFaction(faction)
}
// Add a reasonable number of hostile relationships
for i := int32(1); i <= 100; i++ {
for j := int32(1); j <= 5; j++ {
mfl.AddHostileFaction(i, i+j)
}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 100) + 1)
_ = mfl.GetHostileFactions(factionID)
}
})
b.Run("ValidateFactions", func(b *testing.B) {
mfl := NewMasterList()
// Pre-populate with factions
for i := int32(1); i <= 1000; i++ {
faction := NewFaction(i, "Benchmark Faction", "Test", "Benchmark test")
mfl.AddFaction(faction)
}
// Add some hostile relationships for realistic validation
for i := int32(1); i <= 100; i++ {
mfl.AddHostileFaction(i, i+1)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = mfl.ValidateFactions()
}
})
}
// Benchmark PlayerFaction operations
func BenchmarkPlayerFaction(b *testing.B) {
mfl := NewMasterList()
// Setup factions with proper values
for i := int32(1); i <= 100; i++ {
faction := NewFaction(i, "Player Faction", "Test", "Player test")
faction.PositiveChange = 100
faction.NegativeChange = 50
mfl.AddFaction(faction)
}
pf := NewPlayerFaction(mfl)
// Pre-populate some faction values
for i := int32(1); i <= 100; i++ {
pf.SetFactionValue(i, int32(i*1000))
}
b.Run("GetFactionValue", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 100) + 1)
_ = pf.GetFactionValue(factionID)
}
})
b.Run("SetFactionValue", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 100) + 1)
pf.SetFactionValue(factionID, int32(i))
}
})
b.Run("IncreaseFaction", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 100) + 1)
pf.IncreaseFaction(factionID, 10)
}
})
b.Run("DecreaseFaction", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 100) + 1)
pf.DecreaseFaction(factionID, 5)
}
})
b.Run("GetCon", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 100) + 1)
_ = pf.GetCon(factionID)
}
})
b.Run("GetPercent", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 100) + 1)
_ = pf.GetPercent(factionID)
}
})
b.Run("ShouldAttack", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 100) + 1)
_ = pf.ShouldAttack(factionID)
}
})
b.Run("FactionUpdate", func(b *testing.B) {
// Trigger some updates first
for i := int32(1); i <= 10; i++ {
pf.IncreaseFaction(i, 1)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
pf.FactionUpdate(int16(i % 10))
}
})
}
// Benchmark Manager operations
func BenchmarkManager(b *testing.B) {
manager := NewManager(nil, nil)
// Pre-populate with factions
for i := int32(1); i <= 100; i++ {
faction := NewFaction(i, "Manager Faction", "Test", "Manager test")
manager.AddFaction(faction)
}
b.Run("GetFaction", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 100) + 1)
_ = manager.GetFaction(factionID)
}
})
b.Run("CreatePlayerFaction", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = manager.CreatePlayerFaction()
}
})
b.Run("RecordFactionIncrease", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 100) + 1)
manager.RecordFactionIncrease(factionID)
}
})
b.Run("RecordFactionDecrease", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 100) + 1)
manager.RecordFactionDecrease(factionID)
}
})
b.Run("GetStatistics", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = manager.GetStatistics()
}
})
b.Run("ValidateAllFactions", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = manager.ValidateAllFactions()
}
})
}
// Benchmark EntityFactionAdapter operations
func BenchmarkEntityFactionAdapter(b *testing.B) {
manager := NewManager(nil, nil)
entity := &mockEntity{id: 123, name: "Benchmark Entity", dbID: 456}
adapter := NewEntityFactionAdapter(entity, manager, nil)
// Set up factions and relationships
for i := int32(1); i <= 10; i++ {
faction := NewFaction(i, "Entity Faction", "Test", "Entity test")
manager.AddFaction(faction)
}
mfl := manager.GetMasterFactionList()
mfl.AddHostileFaction(1, 2)
mfl.AddFriendlyFaction(1, 3)
b.Run("GetFactionID", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = adapter.GetFactionID()
}
})
b.Run("SetFactionID", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 10) + 1)
adapter.SetFactionID(factionID)
}
})
b.Run("GetFaction", func(b *testing.B) {
adapter.SetFactionID(1)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = adapter.GetFaction()
}
})
b.Run("IsHostileToFaction", func(b *testing.B) {
adapter.SetFactionID(1)
b.ResetTimer()
for i := 0; i < b.N; i++ {
targetID := int32((i % 10) + 1)
_ = adapter.IsHostileToFaction(targetID)
}
})
b.Run("IsFriendlyToFaction", func(b *testing.B) {
adapter.SetFactionID(1)
b.ResetTimer()
for i := 0; i < b.N; i++ {
targetID := int32((i % 10) + 1)
_ = adapter.IsFriendlyToFaction(targetID)
}
})
b.Run("ValidateFaction", func(b *testing.B) {
adapter.SetFactionID(1)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = adapter.ValidateFaction()
}
})
}
// Benchmark concurrent operations
func BenchmarkConcurrentOperations(b *testing.B) {
b.Run("MasterFactionListConcurrent", func(b *testing.B) {
mfl := NewMasterList()
// Pre-populate
for i := int32(1); i <= 100; i++ {
faction := NewFaction(i, "Concurrent Faction", "Test", "Concurrent test")
mfl.AddFaction(faction)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
factionID := int32((i % 100) + 1)
_ = mfl.GetFaction(factionID)
i++
}
})
})
b.Run("PlayerFactionConcurrent", func(b *testing.B) {
mfl := NewMasterList()
for i := int32(1); i <= 10; i++ {
faction := NewFaction(i, "Player Faction", "Test", "Player test")
faction.PositiveChange = 100
mfl.AddFaction(faction)
}
pf := NewPlayerFaction(mfl)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
factionID := int32((i % 10) + 1)
switch i % 4 {
case 0:
_ = pf.GetFactionValue(factionID)
case 1:
pf.IncreaseFaction(factionID, 1)
case 2:
_ = pf.GetCon(factionID)
case 3:
_ = pf.GetPercent(factionID)
}
i++
}
})
})
b.Run("ManagerConcurrent", func(b *testing.B) {
manager := NewManager(nil, nil)
// Pre-populate
for i := int32(1); i <= 10; i++ {
faction := NewFaction(i, "Manager Faction", "Test", "Manager test")
manager.AddFaction(faction)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
factionID := int32((i % 10) + 1)
switch i % 4 {
case 0:
_ = manager.GetFaction(factionID)
case 1:
manager.RecordFactionIncrease(factionID)
case 2:
_ = manager.GetStatistics()
case 3:
_ = manager.CreatePlayerFaction()
}
i++
}
})
})
}
// Memory allocation benchmarks
func BenchmarkMemoryAllocations(b *testing.B) {
b.Run("FactionCreation", func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = NewFaction(int32(i), "Memory Test", "Test", "Memory test")
}
})
b.Run("MasterFactionListCreation", func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = NewMasterList()
}
})
b.Run("PlayerFactionCreation", func(b *testing.B) {
mfl := NewMasterList()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = NewPlayerFaction(mfl)
}
})
b.Run("ManagerCreation", func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = NewManager(nil, nil)
}
})
b.Run("EntityAdapterCreation", func(b *testing.B) {
manager := NewManager(nil, nil)
entity := &mockEntity{id: 123, name: "Memory Entity", dbID: 456}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = NewEntityFactionAdapter(entity, manager, nil)
}
})
}
// Contention benchmarks
func BenchmarkContention(b *testing.B) {
b.Run("HighContentionReads", func(b *testing.B) {
mfl := NewMasterList()
// Add a single faction that will be accessed heavily
faction := NewFaction(1, "Contention Test", "Test", "Contention test")
mfl.AddFaction(faction)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = mfl.GetFaction(1)
}
})
})
b.Run("HighContentionWrites", func(b *testing.B) {
mfl := NewMasterList()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
factionID := int32(1000000 + i) // Unique IDs to avoid conflicts
faction := NewFaction(factionID, "Write Test", "Test", "Write test")
mfl.AddFaction(faction)
i++
}
})
})
b.Run("MixedReadWrite", func(b *testing.B) {
mfl := NewMasterList()
// Pre-populate
for i := int32(1); i <= 100; i++ {
faction := NewFaction(i, "Mixed Test", "Test", "Mixed test")
mfl.AddFaction(faction)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
if i%10 == 0 {
// 10% writes
factionID := int32(2000000 + i)
faction := NewFaction(factionID, "New Mixed", "Test", "New mixed")
mfl.AddFaction(faction)
} else {
// 90% reads
factionID := int32((i % 100) + 1)
_ = mfl.GetFaction(factionID)
}
i++
}
})
})
}
// Scalability benchmarks
func BenchmarkScalability(b *testing.B) {
sizes := []int{10, 100, 1000, 10000}
for _, size := range sizes {
b.Run("FactionLookup_"+string(rune(size)), func(b *testing.B) {
mfl := NewMasterList()
// Pre-populate with varying sizes
for i := int32(1); i <= int32(size); i++ {
faction := NewFaction(i, "Scale Test", "Test", "Scale test")
mfl.AddFaction(faction)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % size) + 1)
_ = mfl.GetFaction(factionID)
}
})
}
}
// Benchmark bespoke MasterList features
func BenchmarkMasterListBespokeFeatures(b *testing.B) {
// Setup function for consistent test data
setupMasterList := func() *MasterList {
mfl := NewMasterList()
// Add factions across different types
types := []string{"City", "Guild", "Religion", "Race", "Organization"}
for i := 1; i <= 1000; i++ {
factionType := types[i%len(types)]
faction := NewFaction(int32(i), fmt.Sprintf("Faction%d", i), factionType, "Benchmark test")
mfl.AddFaction(faction)
}
return mfl
}
b.Run("GetFactionSafe", func(b *testing.B) {
mfl := setupMasterList()
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 1000) + 1)
_, _ = mfl.GetFactionSafe(factionID)
}
})
b.Run("GetFactionByName", func(b *testing.B) {
mfl := setupMasterList()
names := []string{"faction1", "faction50", "faction100", "faction500", "faction1000"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
name := names[i%len(names)]
_ = mfl.GetFactionByName(name)
}
})
b.Run("GetFactionsByType", func(b *testing.B) {
mfl := setupMasterList()
types := []string{"City", "Guild", "Religion", "Race", "Organization"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionType := types[i%len(types)]
_ = mfl.GetFactionsByType(factionType)
}
})
b.Run("GetSpecialFactions", func(b *testing.B) {
mfl := setupMasterList()
// Add some special factions
for i := int32(1); i <= 5; i++ {
faction := NewFaction(i, fmt.Sprintf("Special%d", i), "Special", "Special faction")
mfl.AddFaction(faction)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = mfl.GetSpecialFactions()
}
})
b.Run("GetRegularFactions", func(b *testing.B) {
mfl := setupMasterList()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = mfl.GetRegularFactions()
}
})
b.Run("GetTypes", func(b *testing.B) {
mfl := setupMasterList()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = mfl.GetTypes()
}
})
b.Run("GetAllFactionsList", func(b *testing.B) {
mfl := setupMasterList()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = mfl.GetAllFactionsList()
}
})
b.Run("GetFactionIDs", func(b *testing.B) {
mfl := setupMasterList()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = mfl.GetFactionIDs()
}
})
b.Run("UpdateFaction", func(b *testing.B) {
mfl := setupMasterList()
b.ResetTimer()
for i := 0; i < b.N; i++ {
factionID := int32((i % 1000) + 1)
updatedFaction := &Faction{
ID: factionID,
Name: fmt.Sprintf("Updated%d", i),
Type: "Updated",
Description: "Updated faction",
}
mfl.UpdateFaction(updatedFaction)
}
})
b.Run("ForEach", func(b *testing.B) {
mfl := setupMasterList()
b.ResetTimer()
for i := 0; i < b.N; i++ {
count := 0
mfl.ForEach(func(id int32, faction *Faction) {
count++
})
}
})
b.Run("GetStatistics", func(b *testing.B) {
mfl := setupMasterList()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = mfl.GetStatistics()
}
})
b.Run("RemoveFaction", func(b *testing.B) {
b.StopTimer()
mfl := setupMasterList()
initialCount := mfl.GetFactionCount()
// Pre-populate with factions we'll remove
for i := 0; i < b.N; i++ {
faction := NewFaction(int32(20000+i), fmt.Sprintf("ToRemove%d", i), "Temporary", "Temporary faction")
mfl.AddFaction(faction)
}
b.StartTimer()
for i := 0; i < b.N; i++ {
mfl.RemoveFaction(int32(20000 + i))
}
b.StopTimer()
if mfl.GetFactionCount() != initialCount {
b.Errorf("Expected %d factions after removal, got %d", initialCount, mfl.GetFactionCount())
}
})
}
// Memory allocation benchmarks for bespoke features
func BenchmarkMasterListBespokeFeatures_Allocs(b *testing.B) {
setupMasterList := func() *MasterList {
mfl := NewMasterList()
types := []string{"City", "Guild", "Religion", "Race", "Organization"}
for i := 1; i <= 100; i++ {
factionType := types[i%len(types)]
faction := NewFaction(int32(i), fmt.Sprintf("Faction%d", i), factionType, "Benchmark test")
mfl.AddFaction(faction)
}
return mfl
}
b.Run("GetFactionByName_Allocs", func(b *testing.B) {
mfl := setupMasterList()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = mfl.GetFactionByName("faction1")
}
})
b.Run("GetFactionsByType_Allocs", func(b *testing.B) {
mfl := setupMasterList()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = mfl.GetFactionsByType("City")
}
})
b.Run("GetTypes_Allocs", func(b *testing.B) {
mfl := setupMasterList()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = mfl.GetTypes()
}
})
b.Run("GetSpecialFactions_Allocs", func(b *testing.B) {
mfl := setupMasterList()
// Add some special factions
for i := int32(1); i <= 5; i++ {
faction := NewFaction(i, fmt.Sprintf("Special%d", i), "Special", "Special faction")
mfl.AddFaction(faction)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = mfl.GetSpecialFactions()
}
})
b.Run("GetStatistics_Allocs", func(b *testing.B) {
mfl := setupMasterList()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = mfl.GetStatistics()
}
})
}
// Concurrent benchmarks for bespoke features
func BenchmarkMasterListBespokeConcurrent(b *testing.B) {
b.Run("ConcurrentReads", func(b *testing.B) {
mfl := NewMasterList()
// Setup test data
types := []string{"City", "Guild", "Religion", "Race", "Organization"}
for i := 1; i <= 100; i++ {
factionType := types[i%len(types)]
faction := NewFaction(int32(i), fmt.Sprintf("Faction%d", i), factionType, "Benchmark test")
mfl.AddFaction(faction)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
// Mix of read operations
switch i % 6 {
case 0:
mfl.GetFaction(int32(i%100 + 1))
case 1:
mfl.GetFactionsByType("City")
case 2:
mfl.GetFactionByName("faction1")
case 3:
mfl.GetSpecialFactions()
case 4:
mfl.GetRegularFactions()
case 5:
mfl.GetTypes()
}
i++
}
})
})
b.Run("ConcurrentMixed", func(b *testing.B) {
mfl := NewMasterList()
// Setup test data
types := []string{"City", "Guild", "Religion", "Race", "Organization"}
for i := 1; i <= 100; i++ {
factionType := types[i%len(types)]
faction := NewFaction(int32(i), fmt.Sprintf("Faction%d", i), factionType, "Benchmark test")
mfl.AddFaction(faction)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
// Mix of read and write operations (mostly reads)
switch i % 10 {
case 0: // 10% writes
faction := NewFaction(int32(i+50000), fmt.Sprintf("Concurrent%d", i), "Concurrent", "Concurrent test")
mfl.AddFaction(faction)
default: // 90% reads
switch i % 5 {
case 0:
mfl.GetFaction(int32(i%100 + 1))
case 1:
mfl.GetFactionsByType("City")
case 2:
mfl.GetFactionByName("faction1")
case 3:
mfl.GetSpecialFactions()
case 4:
mfl.GetTypes()
}
}
i++
}
})
})
}

View File

@ -1,524 +0,0 @@
package factions
import (
"sync"
"testing"
"time"
)
// Stress test MasterFactionList with concurrent operations
func TestMasterFactionListConcurrency(t *testing.T) {
mfl := NewMasterList()
// Pre-populate with some factions
for i := int32(1); i <= 10; i++ {
faction := NewFaction(i, "Test Faction", "Test", "Test faction")
mfl.AddFaction(faction)
}
const numGoroutines = 100
const operationsPerGoroutine = 100
var wg sync.WaitGroup
t.Run("ConcurrentFactionAccess", func(t *testing.T) {
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
factionID := int32((goroutineID % 10) + 1)
// Mix of read operations
faction := mfl.GetFaction(factionID)
if faction == nil {
t.Errorf("Goroutine %d: Expected faction %d to exist", goroutineID, factionID)
}
_ = mfl.GetFactionByName("Test Faction")
_ = mfl.HasFaction(factionID)
_ = mfl.GetFactionCount()
_ = mfl.GetDefaultFactionValue(factionID)
_ = mfl.GetIncreaseAmount(factionID)
_ = mfl.GetDecreaseAmount(factionID)
}
}(i)
}
wg.Wait()
})
t.Run("ConcurrentRelationshipManagement", func(t *testing.T) {
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
factionID := int32((goroutineID % 10) + 1)
targetID := int32(((goroutineID + 1) % 10) + 1)
// Concurrent relationship operations
if goroutineID%2 == 0 {
mfl.AddHostileFaction(factionID, targetID)
_ = mfl.GetHostileFactions(factionID)
} else {
mfl.AddFriendlyFaction(factionID, targetID)
_ = mfl.GetFriendlyFactions(factionID)
}
}
}(i)
}
wg.Wait()
})
t.Run("ConcurrentFactionModification", func(t *testing.T) {
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
newFactionID := int32(1000 + goroutineID*operationsPerGoroutine + j)
faction := NewFaction(newFactionID, "Concurrent Faction", "Test", "Concurrent test")
err := mfl.AddFaction(faction)
if err != nil {
t.Errorf("Goroutine %d: Failed to add faction %d: %v", goroutineID, newFactionID, err)
}
// Verify it was added
retrieved := mfl.GetFaction(newFactionID)
if retrieved == nil {
t.Errorf("Goroutine %d: Failed to retrieve faction %d", goroutineID, newFactionID)
}
}
}(i)
}
wg.Wait()
})
}
// Stress test PlayerFaction with concurrent operations
func TestPlayerFactionConcurrency(t *testing.T) {
mfl := NewMasterList()
// Add test factions with proper values
for i := int32(1); i <= 10; i++ {
faction := NewFaction(i, "Test Faction", "Test", "Test faction")
faction.PositiveChange = 100
faction.NegativeChange = 50
mfl.AddFaction(faction)
}
pf := NewPlayerFaction(mfl)
const numGoroutines = 100
const operationsPerGoroutine = 100
var wg sync.WaitGroup
t.Run("ConcurrentFactionValueOperations", func(t *testing.T) {
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
factionID := int32((goroutineID % 10) + 1)
// Mix of faction value operations
switch j % 4 {
case 0:
pf.IncreaseFaction(factionID, 10)
case 1:
pf.DecreaseFaction(factionID, 5)
case 2:
pf.SetFactionValue(factionID, int32(goroutineID*100))
case 3:
_ = pf.GetFactionValue(factionID)
_ = pf.GetCon(factionID)
_ = pf.GetPercent(factionID)
}
}
}(i)
}
wg.Wait()
})
t.Run("ConcurrentUpdateManagement", func(t *testing.T) {
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
factionID := int32((goroutineID % 10) + 1)
// Operations that trigger updates
pf.IncreaseFaction(factionID, 1)
// Check for pending updates
_ = pf.HasPendingUpdates()
_ = pf.GetPendingUpdates()
// Occasionally clear updates
if j%10 == 0 {
pf.ClearPendingUpdates()
}
}
}(i)
}
wg.Wait()
})
t.Run("ConcurrentPacketGeneration", func(t *testing.T) {
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
// Trigger faction updates
factionID := int32((goroutineID % 10) + 1)
pf.IncreaseFaction(factionID, 1)
// Generate packets concurrently
_, err := pf.FactionUpdate(int16(goroutineID % 10))
if err != nil {
t.Errorf("Goroutine %d: FactionUpdate failed: %v", goroutineID, err)
}
}
}(i)
}
wg.Wait()
})
}
// Stress test Manager with concurrent operations
func TestManagerConcurrency(t *testing.T) {
manager := NewManager(nil, nil) // No database or logger for testing
const numGoroutines = 100
const operationsPerGoroutine = 100
var wg sync.WaitGroup
t.Run("ConcurrentStatisticsOperations", func(t *testing.T) {
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
factionID := int32((goroutineID % 10) + 1)
// Mix of statistics operations
switch j % 4 {
case 0:
manager.RecordFactionIncrease(factionID)
case 1:
manager.RecordFactionDecrease(factionID)
case 2:
_ = manager.GetStatistics()
case 3:
_ = manager.GetFactionCount()
}
}
}(i)
}
wg.Wait()
// Verify statistics consistency
stats := manager.GetStatistics()
totalChanges := stats["total_faction_changes"].(int64)
increases := stats["faction_increases"].(int64)
decreases := stats["faction_decreases"].(int64)
if totalChanges != increases+decreases {
t.Errorf("Statistics inconsistency: total %d != increases %d + decreases %d",
totalChanges, increases, decreases)
}
})
t.Run("ConcurrentFactionManagement", func(t *testing.T) {
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
factionID := int32(2000 + goroutineID*operationsPerGoroutine + j)
faction := NewFaction(factionID, "Manager Test", "Test", "Manager test")
// Add faction
err := manager.AddFaction(faction)
if err != nil {
t.Errorf("Goroutine %d: Failed to add faction %d: %v", goroutineID, factionID, err)
continue
}
// Read operations
_ = manager.GetFaction(factionID)
_ = manager.GetFactionByName("Manager Test")
}
}(i)
}
wg.Wait()
})
t.Run("ConcurrentPlayerFactionCreation", func(t *testing.T) {
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
// Create player factions concurrently
pf := manager.CreatePlayerFaction()
if pf == nil {
t.Errorf("Goroutine %d: CreatePlayerFaction returned nil", goroutineID)
}
}
}(i)
}
wg.Wait()
// Check statistics
stats := manager.GetStatistics()
expectedPlayers := int64(numGoroutines * operationsPerGoroutine)
actualPlayers := stats["players_with_factions"].(int64)
if actualPlayers != expectedPlayers {
t.Errorf("Expected %d players with factions, got %d", expectedPlayers, actualPlayers)
}
})
}
// Stress test EntityFactionAdapter with concurrent operations
func TestEntityFactionAdapterConcurrency(t *testing.T) {
manager := NewManager(nil, nil)
// Mock entity
entity := &mockEntity{id: 123, name: "Test Entity", dbID: 456}
adapter := NewEntityFactionAdapter(entity, manager, nil)
const numGoroutines = 100
const operationsPerGoroutine = 100
var wg sync.WaitGroup
t.Run("ConcurrentFactionIDOperations", func(t *testing.T) {
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
factionID := int32((goroutineID % 10) + 1)
// Mix of faction ID operations
switch j % 3 {
case 0:
adapter.SetFactionID(factionID)
case 1:
_ = adapter.GetFactionID()
case 2:
_ = adapter.GetFaction()
_ = adapter.GetFactionName()
}
}
}(i)
}
wg.Wait()
})
t.Run("ConcurrentRelationshipChecks", func(t *testing.T) {
// Set up some factions first
for i := int32(1); i <= 10; i++ {
faction := NewFaction(i, "Test Faction", "Test", "Test faction")
manager.AddFaction(faction)
}
// Set up relationships
mfl := manager.GetMasterFactionList()
mfl.AddHostileFaction(1, 2)
mfl.AddFriendlyFaction(1, 3)
adapter.SetFactionID(1)
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
otherFactionID := int32((goroutineID % 10) + 1)
// Concurrent relationship checks
_ = adapter.IsHostileToFaction(otherFactionID)
_ = adapter.IsFriendlyToFaction(otherFactionID)
// Validation
_ = adapter.ValidateFaction()
}
}(i)
}
wg.Wait()
})
}
// Mock entity for testing
type mockEntity struct {
id int32
name string
dbID int32
}
func (e *mockEntity) GetID() int32 {
return e.id
}
func (e *mockEntity) GetName() string {
return e.name
}
func (e *mockEntity) GetDatabaseID() int32 {
return e.dbID
}
// Test for potential deadlocks
func TestDeadlockPrevention(t *testing.T) {
mfl := NewMasterList()
manager := NewManager(nil, nil)
// Add test factions
for i := int32(1); i <= 10; i++ {
faction := NewFaction(i, "Test Faction", "Test", "Test faction")
manager.AddFaction(faction)
}
const numGoroutines = 50
var wg sync.WaitGroup
// Test potential deadlock scenarios
t.Run("MixedOperations", func(t *testing.T) {
done := make(chan bool, 1)
// Set a timeout to detect deadlocks
go func() {
time.Sleep(10 * time.Second)
select {
case <-done:
return
default:
t.Error("Potential deadlock detected - test timed out")
}
}()
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < 100; j++ {
factionID := int32((goroutineID % 10) + 1)
// Mix operations that could potentially deadlock
switch j % 6 {
case 0:
_ = mfl.GetFaction(factionID)
_ = manager.GetFaction(factionID)
case 1:
_ = mfl.GetFactionCount()
_ = manager.GetFactionCount()
case 2:
mfl.AddHostileFaction(factionID, factionID+1)
_ = manager.GetStatistics()
case 3:
_ = mfl.GetAllFactions()
manager.RecordFactionIncrease(factionID)
case 4:
_ = mfl.ValidateFactions()
_ = manager.ValidateAllFactions()
case 5:
pf := manager.CreatePlayerFaction()
pf.IncreaseFaction(factionID, 10)
_ = pf.GetFactionValue(factionID)
}
}
}(i)
}
wg.Wait()
done <- true
})
}
// Race condition detection test - run with -race flag
func TestRaceConditions(t *testing.T) {
if testing.Short() {
t.Skip("Skipping race condition test in short mode")
}
// This test is designed to be run with: go test -race
mfl := NewMasterList()
manager := NewManager(nil, nil)
// Rapid concurrent operations to trigger race conditions
const numGoroutines = 200
const operationsPerGoroutine = 50
var wg sync.WaitGroup
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < operationsPerGoroutine; j++ {
factionID := int32((goroutineID % 20) + 1)
// Rapid-fire operations
faction := NewFaction(factionID, "Race Test", "Test", "Race test")
mfl.AddFaction(faction)
_ = mfl.GetFaction(factionID)
mfl.AddHostileFaction(factionID, factionID+1)
_ = mfl.GetHostileFactions(factionID)
manager.AddFaction(faction)
manager.RecordFactionIncrease(factionID)
_ = manager.GetStatistics()
pf := manager.CreatePlayerFaction()
pf.IncreaseFaction(factionID, 1)
_ = pf.GetFactionValue(factionID)
}
}(i)
}
wg.Wait()
}

View File

@ -1,350 +0,0 @@
package factions
import (
"testing"
)
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 := NewMasterList()
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) {
mfl := NewMasterList()
pf := NewPlayerFaction(mfl)
if pf == nil {
t.Fatal("NewPlayerFaction returned nil")
}
// Use faction ID > 10 since IDs <= 10 are special factions that always return 0
testFactionID := int32(100)
// Test setting faction value
pf.SetFactionValue(testFactionID, 1000)
value := pf.GetFactionValue(testFactionID)
if value != 1000 {
t.Errorf("Expected faction value 1000, got %d", value)
}
// Test faction modification
pf.IncreaseFaction(testFactionID, 500)
value = pf.GetFactionValue(testFactionID)
if value != 1500 {
t.Errorf("Expected faction value 1500 after increase, got %d", value)
}
pf.DecreaseFaction(testFactionID, 200)
value = pf.GetFactionValue(testFactionID)
if value != 1300 {
t.Errorf("Expected faction value 1300 after decrease, got %d", value)
}
// Test consideration calculation
consideration := pf.GetCon(testFactionID)
if consideration < -4 || consideration > 4 {
t.Errorf("Consideration %d is out of valid range [-4, 4]", consideration)
}
}
func TestFactionRelations(t *testing.T) {
mfl := NewMasterList()
// 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
mfl.AddHostileFaction(1, 2)
// Check if faction 2 is in the hostile list for faction 1
hostiles := mfl.GetHostileFactions(1)
isHostile := false
for _, hostileID := range hostiles {
if hostileID == 2 {
isHostile = true
break
}
}
if !isHostile {
t.Error("Expected faction 2 to be hostile to faction 1")
}
// Test friendly relations
mfl.AddFriendlyFaction(1, 3)
// Check if faction 3 is in the friendly list for faction 1
friendlies := mfl.GetFriendlyFactions(1)
isFriendly := false
for _, friendlyID := range friendlies {
if friendlyID == 3 {
isFriendly = true
break
}
}
if !isFriendly {
t.Error("Expected faction 3 to be friendly to faction 1")
}
// Test removing relations - need to implement RemoveHostileFaction first
// For now, just verify current state
hostiles = mfl.GetHostileFactions(1)
isHostile = false
for _, hostileID := range hostiles {
if hostileID == 2 {
isHostile = true
break
}
}
if !isHostile {
t.Error("Expected faction 2 to still be hostile to faction 1 (removal not implemented)")
}
}
func TestFactionValidation(t *testing.T) {
mfl := NewMasterList()
// 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")
}
}
func TestMasterListBespokeFeatures(t *testing.T) {
mfl := NewMasterList()
// Create test factions with different properties
faction1 := NewFaction(1, "Special Faction", "Special", "A special faction")
faction2 := NewFaction(20, "City Faction", "City", "A city faction")
faction3 := NewFaction(21, "Guild Faction", "Guild", "A guild faction")
faction4 := NewFaction(30, "Another City", "City", "Another city faction")
// Add factions
mfl.AddFaction(faction1)
mfl.AddFaction(faction2)
mfl.AddFaction(faction3)
mfl.AddFaction(faction4)
// Test GetFactionSafe
retrieved, exists := mfl.GetFactionSafe(20)
if !exists || retrieved == nil {
t.Error("GetFactionSafe should return existing faction and true")
}
_, exists = mfl.GetFactionSafe(9999)
if exists {
t.Error("GetFactionSafe should return false for non-existent ID")
}
// Test GetFactionByName (case-insensitive)
found := mfl.GetFactionByName("city faction")
if found == nil || found.ID != 20 {
t.Error("GetFactionByName should find 'City Faction' (case insensitive)")
}
found = mfl.GetFactionByName("GUILD FACTION")
if found == nil || found.ID != 21 {
t.Error("GetFactionByName should find 'Guild Faction' (uppercase)")
}
found = mfl.GetFactionByName("NonExistent")
if found != nil {
t.Error("GetFactionByName should return nil for non-existent faction")
}
// Test GetFactionsByType
cityFactions := mfl.GetFactionsByType("City")
if len(cityFactions) != 2 {
t.Errorf("GetFactionsByType('City') returned %v results, want 2", len(cityFactions))
}
guildFactions := mfl.GetFactionsByType("Guild")
if len(guildFactions) != 1 {
t.Errorf("GetFactionsByType('Guild') returned %v results, want 1", len(guildFactions))
}
// Test GetSpecialFactions and GetRegularFactions
specialFactions := mfl.GetSpecialFactions()
if len(specialFactions) != 1 {
t.Errorf("GetSpecialFactions() returned %v results, want 1", len(specialFactions))
}
regularFactions := mfl.GetRegularFactions()
if len(regularFactions) != 3 {
t.Errorf("GetRegularFactions() returned %v results, want 3", len(regularFactions))
}
// Test GetTypes
types := mfl.GetTypes()
if len(types) < 3 {
t.Errorf("GetTypes() returned %v types, want at least 3", len(types))
}
// Verify types contains expected values
typeMap := make(map[string]bool)
for _, factionType := range types {
typeMap[factionType] = true
}
if !typeMap["Special"] || !typeMap["City"] || !typeMap["Guild"] {
t.Error("GetTypes() should contain 'Special', 'City', and 'Guild'")
}
// Test UpdateFaction
updatedFaction := &Faction{
ID: 20,
Name: "Updated City Faction",
Type: "UpdatedCity",
Description: "An updated city faction",
}
err := mfl.UpdateFaction(updatedFaction)
if err != nil {
t.Errorf("UpdateFaction failed: %v", err)
}
// Verify the update worked
retrieved = mfl.GetFaction(20)
if retrieved.Name != "Updated City Faction" {
t.Errorf("Expected updated name 'Updated City Faction', got '%s'", retrieved.Name)
}
if retrieved.Type != "UpdatedCity" {
t.Errorf("Expected updated type 'UpdatedCity', got '%s'", retrieved.Type)
}
// Test updating non-existent faction
nonExistentFaction := &Faction{ID: 9999, Name: "Non-existent"}
err = mfl.UpdateFaction(nonExistentFaction)
if err == nil {
t.Error("UpdateFaction should fail for non-existent faction")
}
// Test GetAllFactionsList
allList := mfl.GetAllFactionsList()
if len(allList) != 4 {
t.Errorf("GetAllFactionsList() returned %v factions, want 4", len(allList))
}
// Test GetFactionIDs
ids := mfl.GetFactionIDs()
if len(ids) != 4 {
t.Errorf("GetFactionIDs() returned %v IDs, want 4", len(ids))
}
}
func TestMasterListConcurrency(t *testing.T) {
mfl := NewMasterList()
// Add initial factions
for i := 1; i <= 50; i++ {
faction := NewFaction(int32(i+100), "Faction", "Test", "Test faction")
mfl.AddFaction(faction)
}
// Test concurrent access
done := make(chan bool, 10)
// Concurrent readers
for i := 0; i < 5; i++ {
go func() {
defer func() { done <- true }()
for j := 0; j < 100; j++ {
mfl.GetFaction(int32(j%50 + 101))
mfl.GetFactionsByType("Test")
mfl.GetFactionByName("faction")
mfl.HasFaction(int32(j%50 + 101))
}
}()
}
// Concurrent writers
for i := 0; i < 5; i++ {
go func(workerID int) {
defer func() { done <- true }()
for j := 0; j < 10; j++ {
factionID := int32(workerID*1000 + j + 1000)
faction := NewFaction(factionID, "Worker Faction", "Worker", "Worker test faction")
mfl.AddFaction(faction) // Some may fail due to concurrent additions
}
}(i)
}
// Wait for all goroutines
for i := 0; i < 10; i++ {
<-done
}
// Verify final state - should have at least 50 initial factions
finalCount := mfl.GetFactionCount()
if finalCount < 50 {
t.Errorf("Expected at least 50 factions after concurrent operations, got %d", finalCount)
}
if finalCount > 100 {
t.Errorf("Expected at most 100 factions after concurrent operations, got %d", finalCount)
}
}

View File

@ -1,524 +0,0 @@
package ground_spawn
import (
"fmt"
"math/rand"
"sync"
"testing"
)
// Mock implementations for benchmarking
// mockPlayer implements Player interface for benchmarks
type mockPlayer struct {
level int16
location int32
name string
}
func (p *mockPlayer) GetLevel() int16 { return p.level }
func (p *mockPlayer) GetLocation() int32 { return p.location }
func (p *mockPlayer) GetName() string { return p.name }
// mockSkill implements Skill interface for benchmarks
type mockSkill struct {
current int16
max int16
}
func (s *mockSkill) GetCurrentValue() int16 { return s.current }
func (s *mockSkill) GetMaxValue() int16 { return s.max }
// createTestGroundSpawn creates a ground spawn for benchmarking
func createTestGroundSpawn(b *testing.B, id int32) *GroundSpawn {
b.Helper()
// Don't create a database for every ground spawn - that's extremely expensive!
// Pass nil for the database since we're just benchmarking the in-memory operations
gs := New(nil)
gs.GroundSpawnID = id
gs.Name = fmt.Sprintf("Benchmark Node %d", id)
gs.CollectionSkill = "Mining"
gs.NumberHarvests = 5
gs.AttemptsPerHarvest = 1
gs.CurrentHarvests = 5
gs.X, gs.Y, gs.Z = float32(rand.Intn(1000)), float32(rand.Intn(1000)), float32(rand.Intn(1000))
gs.ZoneID = int32(rand.Intn(10) + 1)
gs.GridID = int32(rand.Intn(100))
// Add mock harvest entries for realistic benchmarking
gs.HarvestEntries = []*HarvestEntry{
{
GroundSpawnID: id,
MinSkillLevel: 10,
MinAdventureLevel: 1,
BonusTable: false,
Harvest1: 80.0,
Harvest3: 20.0,
Harvest5: 10.0,
HarvestImbue: 5.0,
HarvestRare: 2.0,
Harvest10: 1.0,
},
{
GroundSpawnID: id,
MinSkillLevel: 50,
MinAdventureLevel: 10,
BonusTable: true,
Harvest1: 90.0,
Harvest3: 30.0,
Harvest5: 15.0,
HarvestImbue: 8.0,
HarvestRare: 5.0,
Harvest10: 2.0,
},
}
// Add mock harvest items
gs.HarvestItems = []*HarvestEntryItem{
{GroundSpawnID: id, ItemID: 1001, IsRare: ItemRarityNormal, GridID: 0, Quantity: 1},
{GroundSpawnID: id, ItemID: 1002, IsRare: ItemRarityNormal, GridID: 0, Quantity: 1},
{GroundSpawnID: id, ItemID: 1003, IsRare: ItemRarityRare, GridID: 0, Quantity: 1},
{GroundSpawnID: id, ItemID: 1004, IsRare: ItemRarityImbue, GridID: 0, Quantity: 1},
}
return gs
}
// BenchmarkGroundSpawnCreation measures ground spawn creation performance
func BenchmarkGroundSpawnCreation(b *testing.B) {
b.Skip("Skipping benchmark test - requires MySQL database connection")
// TODO: Set up proper MySQL test database for benchmarks
// db, err := database.NewMySQL("user:pass@tcp(localhost:3306)/test")
// if err != nil {
// b.Fatalf("Failed to create test database: %v", err)
// }
// defer db.Close()
b.ResetTimer()
// b.Run("Sequential", func(b *testing.B) {
// for i := 0; i < b.N; i++ {
// gs := New(db)
// gs.GroundSpawnID = int32(i)
// gs.Name = fmt.Sprintf("Node %d", i)
// _ = gs
// }
// })
// b.Run("Parallel", func(b *testing.B) {
// b.RunParallel(func(pb *testing.PB) {
// id := int32(0)
// for pb.Next() {
// gs := New(db)
// gs.GroundSpawnID = id
// gs.Name = fmt.Sprintf("Node %d", id)
// id++
// _ = gs
// }
// })
// })
}
// BenchmarkGroundSpawnState measures state operations
func BenchmarkGroundSpawnState(b *testing.B) {
gs := createTestGroundSpawn(b, 1001)
b.Run("IsAvailable", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = gs.IsAvailable()
}
})
})
b.Run("IsDepleted", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = gs.IsDepleted()
}
})
})
b.Run("GetHarvestMessageName", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = gs.GetHarvestMessageName(true, false)
}
})
})
b.Run("GetHarvestSpellType", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = gs.GetHarvestSpellType()
}
})
})
}
// BenchmarkHarvestAlgorithm measures the core harvest processing performance
func BenchmarkHarvestAlgorithm(b *testing.B) {
gs := createTestGroundSpawn(b, 1001)
player := &mockPlayer{level: 50, location: 1, name: "BenchmarkPlayer"}
skill := &mockSkill{current: 75, max: 100}
b.Run("ProcessHarvest", func(b *testing.B) {
for b.Loop() {
// Reset harvest count for consistent benchmarking
gs.CurrentHarvests = gs.NumberHarvests
_, err := gs.ProcessHarvest(player, skill, 75)
if err != nil {
b.Fatalf("Harvest failed: %v", err)
}
}
})
b.Run("FilterHarvestTables", func(b *testing.B) {
for b.Loop() {
_ = gs.filterHarvestTables(player, 75)
}
})
b.Run("SelectHarvestTable", func(b *testing.B) {
tables := gs.filterHarvestTables(player, 75)
for b.Loop() {
_ = gs.selectHarvestTable(tables, 75)
}
})
b.Run("DetermineHarvestType", func(b *testing.B) {
tables := gs.filterHarvestTables(player, 75)
table := gs.selectHarvestTable(tables, 75)
for b.Loop() {
_ = gs.determineHarvestType(table, false)
}
})
b.Run("AwardHarvestItems", func(b *testing.B) {
for b.Loop() {
_ = gs.awardHarvestItems(HarvestType3Items, player)
}
})
}
// Global shared master list for benchmarks to avoid repeated setup
var (
sharedMasterList *MasterList
sharedSpawns []*GroundSpawn
setupOnce sync.Once
)
// setupSharedMasterList creates the shared master list once
func setupSharedMasterList(b *testing.B) {
setupOnce.Do(func() {
sharedMasterList = NewMasterList()
// Pre-populate with ground spawns for realistic testing
const numSpawns = 1000
sharedSpawns = make([]*GroundSpawn, numSpawns)
for i := range numSpawns {
sharedSpawns[i] = createTestGroundSpawn(b, int32(i+1))
sharedSpawns[i].ZoneID = int32(i%10 + 1) // Distribute across 10 zones
sharedSpawns[i].CollectionSkill = []string{"Mining", "Gathering", "Fishing", "Trapping"}[i%4]
// Create realistic spatial clusters within each zone
zoneBase := float32(sharedSpawns[i].ZoneID * 1000)
cluster := float32((i % 100) * 50) // Clusters of ~25 spawns
sharedSpawns[i].X = zoneBase + cluster + float32(rand.Intn(100)-50) // Add some spread
sharedSpawns[i].Y = zoneBase + cluster + float32(rand.Intn(100)-50)
sharedSpawns[i].Z = float32(rand.Intn(100))
sharedMasterList.AddGroundSpawn(sharedSpawns[i])
}
})
}
// BenchmarkMasterListOperations measures master list performance
func BenchmarkMasterListOperations(b *testing.B) {
setupSharedMasterList(b)
ml := sharedMasterList
b.Run("GetGroundSpawn", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
id := int32((rand.Intn(1000) + 1))
_ = ml.GetGroundSpawn(id)
}
})
})
b.Run("AddGroundSpawn", func(b *testing.B) {
// Create a separate master list for add operations to avoid contaminating shared list
addML := NewMasterList()
startID := int32(10000)
// Pre-create ground spawns to measure just the Add operation
spawnsToAdd := make([]*GroundSpawn, b.N)
for i := 0; i < b.N; i++ {
spawnsToAdd[i] = createTestGroundSpawn(b, startID+int32(i))
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
addML.AddGroundSpawn(spawnsToAdd[i])
}
})
b.Run("GetByZone", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
zoneID := int32(rand.Intn(10) + 1)
_ = ml.GetByZone(zoneID)
}
})
})
b.Run("GetBySkill", func(b *testing.B) {
skills := []string{"Mining", "Gathering", "Fishing", "Trapping"}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
skill := skills[rand.Intn(len(skills))]
_ = ml.GetBySkill(skill)
}
})
})
b.Run("GetAvailableSpawns", func(b *testing.B) {
for b.Loop() {
_ = ml.GetAvailableSpawns()
}
})
b.Run("GetDepletedSpawns", func(b *testing.B) {
for b.Loop() {
_ = ml.GetDepletedSpawns()
}
})
b.Run("GetStatistics", func(b *testing.B) {
for b.Loop() {
_ = ml.GetStatistics()
}
})
}
// BenchmarkConcurrentHarvesting measures concurrent harvest performance
func BenchmarkConcurrentHarvesting(b *testing.B) {
const numSpawns = 100
spawns := make([]*GroundSpawn, numSpawns)
for i := 0; i < numSpawns; i++ {
spawns[i] = createTestGroundSpawn(b, int32(i+1))
}
player := &mockPlayer{level: 50, location: 1, name: "BenchmarkPlayer"}
skill := &mockSkill{current: 75, max: 100}
b.Run("ConcurrentHarvesting", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
spawnIdx := rand.Intn(numSpawns)
gs := spawns[spawnIdx]
// Reset if depleted
if gs.IsDepleted() {
gs.Respawn()
}
_, _ = gs.ProcessHarvest(player, skill, 75)
}
})
})
}
// BenchmarkMemoryAllocation measures memory allocation patterns
func BenchmarkMemoryAllocation(b *testing.B) {
b.Skip("Skipping benchmark test - requires MySQL database connection")
// TODO: Set up proper MySQL test database for benchmarks
// db, err := database.NewMySQL("user:pass@tcp(localhost:3306)/test")
// if err != nil {
// b.Fatalf("Failed to create test database: %v", err)
// }
// defer db.Close()
// b.Run("GroundSpawnAllocation", func(b *testing.B) {
// b.ReportAllocs()
// for i := 0; i < b.N; i++ {
// gs := New(db)
// gs.GroundSpawnID = int32(i)
// gs.HarvestEntries = make([]*HarvestEntry, 2)
// gs.HarvestItems = make([]*HarvestEntryItem, 4)
// _ = gs
// }
// })
b.Run("MasterListAllocation", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
ml := NewMasterList()
_ = ml
}
})
b.Run("HarvestResultAllocation", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
result := &HarvestResult{
Success: true,
HarvestType: HarvestType3Items,
ItemsAwarded: make([]*HarvestedItem, 3),
MessageText: "You harvested 3 items",
SkillGained: false,
}
_ = result
}
})
b.Run("SpatialAddGroundSpawn_Allocations", func(b *testing.B) {
b.ReportAllocs()
ml := NewMasterList()
for i := 0; i < b.N; i++ {
gs := createTestGroundSpawn(b, int32(i+1))
gs.ZoneID = int32(i%10 + 1)
gs.CollectionSkill = []string{"Mining", "Gathering", "Fishing", "Trapping"}[i%4]
gs.X = float32(rand.Intn(1000))
gs.Y = float32(rand.Intn(1000))
ml.AddGroundSpawn(gs)
}
})
b.Run("SpatialGetNearby_Allocations", func(b *testing.B) {
setupSharedMasterList(b)
ml := sharedMasterList
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
x := float32(rand.Intn(10000))
y := float32(rand.Intn(10000))
_ = ml.GetNearby(x, y, 100.0)
}
})
}
// BenchmarkRespawnOperations measures respawn performance
func BenchmarkRespawnOperations(b *testing.B) {
gs := createTestGroundSpawn(b, 1001)
b.Run("Respawn", func(b *testing.B) {
for b.Loop() {
gs.CurrentHarvests = 0 // Deplete
gs.Respawn()
}
})
b.Run("RespawnWithRandomHeading", func(b *testing.B) {
gs.RandomizeHeading = true
for b.Loop() {
gs.CurrentHarvests = 0 // Deplete
gs.Respawn()
}
})
}
// BenchmarkStringOperations measures string processing performance
func BenchmarkStringOperations(b *testing.B) {
skills := []string{"Mining", "Gathering", "Collecting", "Fishing", "Trapping", "Foresting", "Unknown"}
b.Run("HarvestMessageGeneration", func(b *testing.B) {
gs := createTestGroundSpawn(b, 1001)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
skill := skills[rand.Intn(len(skills))]
gs.CollectionSkill = skill
_ = gs.GetHarvestMessageName(rand.Intn(2) == 1, rand.Intn(2) == 1)
}
})
})
b.Run("SpellTypeGeneration", func(b *testing.B) {
gs := createTestGroundSpawn(b, 1001)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
skill := skills[rand.Intn(len(skills))]
gs.CollectionSkill = skill
_ = gs.GetHarvestSpellType()
}
})
})
}
// BenchmarkSpatialFeatures tests features unique to spatial implementation
func BenchmarkSpatialFeatures(b *testing.B) {
setupSharedMasterList(b)
ml := sharedMasterList
b.Run("GetByZoneAndSkill", func(b *testing.B) {
skills := []string{"Mining", "Gathering", "Fishing", "Trapping"}
for b.Loop() {
zoneID := int32(rand.Intn(10) + 1)
skill := skills[rand.Intn(len(skills))]
_ = ml.GetByZoneAndSkill(zoneID, skill)
}
})
b.Run("GetNearby_Small", func(b *testing.B) {
for b.Loop() {
x := float32(rand.Intn(10000))
y := float32(rand.Intn(10000))
_ = ml.GetNearby(x, y, 50.0) // Small radius
}
})
b.Run("GetNearby_Medium", func(b *testing.B) {
for b.Loop() {
x := float32(rand.Intn(10000))
y := float32(rand.Intn(10000))
_ = ml.GetNearby(x, y, 200.0) // Medium radius
}
})
b.Run("GetNearby_Large", func(b *testing.B) {
for b.Loop() {
x := float32(rand.Intn(10000))
y := float32(rand.Intn(10000))
_ = ml.GetNearby(x, y, 500.0) // Large radius
}
})
}
// BenchmarkConcurrentSpatialOperations tests thread safety and mixed workloads
func BenchmarkConcurrentSpatialOperations(b *testing.B) {
setupSharedMasterList(b)
ml := sharedMasterList
b.Run("MixedSpatialOperations", func(b *testing.B) {
skills := []string{"Mining", "Gathering", "Fishing", "Trapping"}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
switch rand.Intn(6) {
case 0:
id := int32(rand.Intn(1000) + 1)
_ = ml.GetGroundSpawn(id)
case 1:
zoneID := int32(rand.Intn(10) + 1)
_ = ml.GetByZone(zoneID)
case 2:
skill := skills[rand.Intn(len(skills))]
_ = ml.GetBySkill(skill)
case 3:
zoneID := int32(rand.Intn(10) + 1)
skill := skills[rand.Intn(len(skills))]
_ = ml.GetByZoneAndSkill(zoneID, skill)
case 4:
x := float32(rand.Intn(10000))
y := float32(rand.Intn(10000))
_ = ml.GetNearby(x, y, 100.0)
case 5:
_ = ml.GetAvailableSpawns()
}
}
})
})
}

View File

@ -1,192 +0,0 @@
package ground_spawn
import (
"testing"
)
func TestNew(t *testing.T) {
// Test creating a new ground spawn
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database
// db, err := database.NewMySQL("user:pass@tcp(localhost:3306)/test")
// if err != nil {
// t.Fatalf("Failed to create test database: %v", err)
// }
// defer db.Close()
// gs := New(db)
// if gs == nil {
// t.Fatal("Expected non-nil ground spawn")
// }
// if gs.db != db {
// t.Error("Database connection not set correctly")
// }
// if !gs.isNew {
// t.Error("New ground spawn should be marked as new")
// }
// if !gs.IsAlive {
// t.Error("New ground spawn should be alive")
// }
// if gs.RandomizeHeading != true {
// t.Error("Default RandomizeHeading should be true")
// }
}
func TestGroundSpawnGetID(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database
// db, err := database.NewMySQL("user:pass@tcp(localhost:3306)/test")
// if err != nil {
// t.Fatalf("Failed to create test database: %v", err)
// }
// defer db.Close()
// gs := New(db)
// gs.GroundSpawnID = 12345
// if gs.GetID() != 12345 {
// t.Errorf("Expected GetID() to return 12345, got %d", gs.GetID())
// }
}
func TestGroundSpawnState(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database
// db, err := database.NewMySQL("user:pass@tcp(localhost:3306)/test")
// if err != nil {
// t.Fatalf("Failed to create test database: %v", err)
// }
// defer db.Close()
// gs := New(db)
// gs.NumberHarvests = 5
// gs.CurrentHarvests = 3
// if gs.IsDepleted() {
// t.Error("Ground spawn with harvests should not be depleted")
// }
// if !gs.IsAvailable() {
// t.Error("Ground spawn with harvests should be available")
// }
// gs.CurrentHarvests = 0
// if !gs.IsDepleted() {
// t.Error("Ground spawn with no harvests should be depleted")
// }
// if gs.IsAvailable() {
// t.Error("Depleted ground spawn should not be available")
// }
}
func TestHarvestMessageName(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database
// db, err := database.NewMySQL("user:pass@tcp(localhost:3306)/test")
// if err != nil {
// t.Fatalf("Failed to create test database: %v", err)
// }
// defer db.Close()
// testCases := []struct {
// skill string
// presentTense bool
// failure bool
// expectedVerb string
// }{
// {"Mining", true, false, "mine"},
// {"Mining", false, false, "mined"},
// {"Gathering", true, false, "gather"},
// {"Gathering", false, false, "gathered"},
// {"Fishing", true, false, "fish"},
// {"Fishing", false, false, "fished"},
// {"Unknown", true, false, "collect"},
// {"Unknown", false, false, "collected"},
// }
// for _, tc := range testCases {
// gs := New(db)
// gs.CollectionSkill = tc.skill
//
// result := gs.GetHarvestMessageName(tc.presentTense, tc.failure)
// if result != tc.expectedVerb {
// t.Errorf("For skill %s (present=%v, failure=%v), expected %s, got %s",
// tc.skill, tc.presentTense, tc.failure, tc.expectedVerb, result)
// }
// }
}
func TestNewMasterList(t *testing.T) {
ml := NewMasterList()
if ml == nil {
t.Fatal("Expected non-nil master list")
}
if ml.Size() != 0 {
t.Error("New master list should be empty")
}
}
func TestMasterListOperations(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection")
// TODO: Set up proper MySQL test database
// db, err := database.NewMySQL("user:pass@tcp(localhost:3306)/test")
// if err != nil {
// t.Fatalf("Failed to create test database: %v", err)
// }
// defer db.Close()
// ml := NewMasterList()
//
// // Create test ground spawn
// gs := New(db)
// gs.GroundSpawnID = 1001
// gs.Name = "Test Node"
// gs.CollectionSkill = "Mining"
// gs.ZoneID = 1
// gs.CurrentHarvests = 5
// // Test add
// if !ml.AddGroundSpawn(gs) {
// t.Error("Should be able to add new ground spawn")
// }
// // Test get
// retrieved := ml.GetGroundSpawn(1001)
// if retrieved == nil {
// t.Fatal("Should be able to retrieve added ground spawn")
// }
// if retrieved.Name != "Test Node" {
// t.Errorf("Expected name 'Test Node', got '%s'", retrieved.Name)
// }
// // Test zone filter
// zoneSpawns := ml.GetByZone(1)
// if len(zoneSpawns) != 1 {
// t.Errorf("Expected 1 spawn in zone 1, got %d", len(zoneSpawns))
// }
// // Test skill filter
// miningSpawns := ml.GetBySkill("Mining")
// if len(miningSpawns) != 1 {
// t.Errorf("Expected 1 mining spawn, got %d", len(miningSpawns))
// }
// // Test available spawns
// available := ml.GetAvailableSpawns()
// if len(available) != 1 {
// t.Errorf("Expected 1 available spawn, got %d", len(available))
// }
// // Test depleted spawns (should be none)
// depleted := ml.GetDepletedSpawns()
// if len(depleted) != 0 {
// t.Errorf("Expected 0 depleted spawns, got %d", len(depleted))
// }
}

View File

@ -1,446 +0,0 @@
# Groups System
The groups system (`internal/groups`) provides comprehensive player group and raid management for the EQ2Go server emulator. This system is converted from the original C++ EQ2EMu PlayerGroups implementation with modern Go concurrency patterns and clean architecture principles.
## Overview
The groups system manages all aspects of player groups and raids including:
- **Group Management**: Creation, disbanding, member management
- **Raid Functionality**: Multi-group coordination with up to 4 groups (24 players)
- **Cross-Server Groups**: Peer-to-peer group coordination across server instances
- **Group Invitations**: Invitation system with timeouts and validation
- **Group Communication**: Chat, messaging, and broadcast systems
- **Group Options**: Loot distribution, auto-split, leadership settings
- **Quest Sharing**: Share quests with group members
- **Group Buffs**: Coordinated buff management across group members
- **Statistics**: Comprehensive group activity tracking
## Architecture
### Core Components
**Group** - Individual group with up to 6 members, options, and raid functionality
**GroupManager** - Global group management, invitations, and coordination
**Service** - High-level service interface with validation and configuration
**GroupMemberInfo** - Detailed member information and statistics
**GroupOptions** - Group behavior and loot configuration
### Key Files
- `group.go` - Core Group struct and member management
- `manager.go` - GroupManager with global group coordination
- `service.go` - High-level Service interface with validation
- `types.go` - Data structures and type definitions
- `interfaces.go` - System integration interfaces and adapters
- `constants.go` - All group system constants and limits
- `README.md` - This documentation
## Group Creation and Management
```go
// Create group service
config := groups.DefaultServiceConfig()
service := groups.NewService(config)
service.Start()
// Create a new group
leader := &entity.Player{...}
options := &groups.GroupOptions{
LootMethod: groups.LOOT_METHOD_ROUND_ROBIN,
AutoSplit: groups.AUTO_SPLIT_ENABLED,
}
groupID, err := service.CreateGroup(leader, options)
// Get group information
groupInfo, err := service.GetGroupInfo(groupID)
fmt.Printf("Group %d has %d members\n", groupInfo.GroupID, groupInfo.Size)
```
## Group Invitations
```go
// Invite a player to the group
leader := &entity.Player{...}
member := &entity.Player{...}
// Send invitation
err := service.InviteToGroup(leader, member)
if err != nil {
fmt.Printf("Invitation failed: %v\n", err)
}
// Accept invitation
err = service.AcceptGroupInvite(member)
if err != nil {
fmt.Printf("Failed to accept: %v\n", err)
}
// Decline invitation
service.DeclineGroupInvite(member)
```
## Group Member Management
```go
// Get group manager directly
manager := service.GetManager()
// Add member to existing group
err := manager.AddGroupMember(groupID, member, false)
// Remove member from group
err := manager.RemoveGroupMember(groupID, member)
// Transfer leadership
err := service.TransferLeadership(groupID, newLeader)
// Check if entity is in group
isInGroup := manager.IsInGroup(groupID, member)
// Get group leader
leader := manager.GetGroupLeader(groupID)
```
## Group Communication
```go
// Send simple message to group
manager.SimpleGroupMessage(groupID, "Welcome to the group!")
// Send system message
manager.SendGroupMessage(groupID, groups.GROUP_MESSAGE_TYPE_SYSTEM, "Group is ready!")
// Send chat message from member
manager.GroupChatMessage(groupID, fromEntity, 0, "Hello everyone!", groups.CHANNEL_GROUP_SAY)
// Send formatted chat message
manager.SendGroupChatMessage(groupID, groups.CHANNEL_GROUP_CHAT, "Raid starting in 5 minutes")
```
## Group Options Configuration
```go
// Configure group options
options := groups.GroupOptions{
LootMethod: groups.LOOT_METHOD_NEED_BEFORE_GREED,
LootItemsRarity: groups.LOOT_RARITY_RARE,
AutoSplit: groups.AUTO_SPLIT_ENABLED,
GroupLockMethod: groups.LOCK_METHOD_INVITE_ONLY,
GroupAutolock: groups.AUTO_LOCK_ENABLED,
AutoLootMethod: groups.AUTO_LOOT_ENABLED,
}
// Apply options to group
err := manager.SetGroupOptions(groupID, &options)
// Get current options
currentOptions, exists := manager.GetDefaultGroupOptions(groupID)
if exists {
fmt.Printf("Loot method: %d\n", currentOptions.LootMethod)
}
```
## Raid Management
```go
// Form a raid from multiple groups
leaderGroupID := int32(1)
targetGroups := []int32{2, 3, 4}
err := service.FormRaid(leaderGroupID, targetGroups)
if err != nil {
fmt.Printf("Failed to form raid: %v\n", err)
}
// Check if groups are in same raid
isInRaid := manager.IsInRaidGroup(groupID1, groupID2, false)
// Get all raid groups for a group
raidGroups := manager.GetRaidGroups(groupID)
fmt.Printf("Raid has %d groups\n", len(raidGroups))
// Disband raid
err := service.DisbandRaid(leaderGroupID)
```
## Cross-Server Group Management
```go
// Add member from peer server
memberInfo := &groups.GroupMemberInfo{
Name: "RemotePlayer",
Leader: false,
IsClient: true,
ClassID: 1,
HPCurrent: 1500,
HPMax: 1500,
PowerCurrent: 800,
PowerMax: 800,
LevelCurrent: 50,
LevelMax: 50,
RaceID: 0,
Zone: "commonlands",
ZoneID: 220,
InstanceID: 0,
ClientPeerAddress: "192.168.1.10",
ClientPeerPort: 9000,
IsRaidLooter: false,
}
err := manager.AddGroupMemberFromPeer(groupID, memberInfo)
// Remove peer member by name
err = manager.RemoveGroupMemberByName(groupID, "RemotePlayer", true, 12345)
```
## Group Statistics and Information
```go
// Get service statistics
stats := service.GetServiceStats()
fmt.Printf("Active groups: %d\n", stats.ManagerStats.ActiveGroups)
fmt.Printf("Total invites: %d\n", stats.ManagerStats.TotalInvites)
fmt.Printf("Average group size: %.1f\n", stats.ManagerStats.AverageGroupSize)
// Get all groups in a zone
zoneGroups := service.GetGroupsByZone(zoneID)
for _, group := range zoneGroups {
fmt.Printf("Group %d has %d members in zone\n", group.GroupID, group.Size)
}
// Get groups containing specific members
members := []entity.Entity{player1, player2}
memberGroups := service.GetMemberGroups(members)
```
## Event Handling
```go
// Create custom event handler
type MyGroupEventHandler struct{}
func (h *MyGroupEventHandler) OnGroupCreated(group *groups.Group, leader entity.Entity) error {
fmt.Printf("Group %d created by %s\n", group.GetID(), leader.GetName())
return nil
}
func (h *MyGroupEventHandler) OnGroupMemberJoined(group *groups.Group, member entity.Entity) error {
fmt.Printf("%s joined group %d\n", member.GetName(), group.GetID())
return nil
}
func (h *MyGroupEventHandler) OnGroupDisbanded(group *groups.Group) error {
fmt.Printf("Group %d disbanded\n", group.GetID())
return nil
}
// ... implement other required methods
// Register event handler
handler := &MyGroupEventHandler{}
service.AddEventHandler(handler)
```
## Database Integration
```go
// Implement database interface
type MyGroupDatabase struct {
// database connection
}
func (db *MyGroupDatabase) SaveGroup(group *groups.Group) error {
// Save group to database
return nil
}
func (db *MyGroupDatabase) LoadGroup(groupID int32) (*groups.Group, error) {
// Load group from database
return nil, nil
}
// ... implement other required methods
// Set database interface
database := &MyGroupDatabase{}
service.SetDatabase(database)
```
## Packet Handling Integration
```go
// Implement packet handler interface
type MyGroupPacketHandler struct {
// client connection management
}
func (ph *MyGroupPacketHandler) SendGroupUpdate(members []*groups.GroupMemberInfo, excludeClient any) error {
// Send group update packets to clients
return nil
}
func (ph *MyGroupPacketHandler) SendGroupInvite(inviter, invitee entity.Entity) error {
// Send invitation packet to client
return nil
}
// ... implement other required methods
// Set packet handler
packetHandler := &MyGroupPacketHandler{}
service.SetPacketHandler(packetHandler)
```
## Validation and Security
```go
// Implement custom validator
type MyGroupValidator struct{}
func (v *MyGroupValidator) ValidateGroupCreation(leader entity.Entity, options *groups.GroupOptions) error {
// Custom validation logic
if leader.GetLevel() < 10 {
return fmt.Errorf("must be level 10+ to create groups")
}
return nil
}
func (v *MyGroupValidator) ValidateGroupInvite(leader, member entity.Entity) error {
// Custom invitation validation
if leader.GetZone() != member.GetZone() {
return fmt.Errorf("cross-zone invites not allowed")
}
return nil
}
// ... implement other required methods
// Set validator
validator := &MyGroupValidator{}
service.SetValidator(validator)
```
## Configuration
### Service Configuration
```go
config := groups.ServiceConfig{
ManagerConfig: groups.GroupManagerConfig{
MaxGroups: 1000,
MaxRaidGroups: 4,
InviteTimeout: 30 * time.Second,
UpdateInterval: 1 * time.Second,
BuffUpdateInterval: 5 * time.Second,
EnableCrossServer: true,
EnableRaids: true,
EnableQuestSharing: true,
EnableStatistics: true,
},
AutoCreateGroups: true,
AllowCrossZoneGroups: true,
AllowBotMembers: true,
AllowNPCMembers: false,
MaxInviteDistance: 100.0,
GroupLevelRange: 10,
EnableGroupPvP: false,
EnableGroupBuffs: true,
DatabaseEnabled: true,
EventsEnabled: true,
StatisticsEnabled: true,
ValidationEnabled: true,
}
service := groups.NewService(config)
```
### Group Options
Available group options for loot and behavior management:
- **Loot Methods**: Leader only, round robin, need before greed, lotto
- **Loot Rarity**: Common, uncommon, rare, legendary, fabled
- **Auto Split**: Enable/disable automatic coin splitting
- **Group Lock**: Open, invite only, closed
- **Auto Lock**: Automatic group locking settings
- **Auto Loot**: Automatic loot distribution
## Constants and Limits
### Group Limits
- **MAX_GROUP_SIZE**: 6 members per group
- **MAX_RAID_GROUPS**: 4 groups per raid
- **MAX_RAID_SIZE**: 24 total raid members
### Invitation System
- **Default invite timeout**: 30 seconds
- **Invitation error codes**: Success, already in group, group full, declined, etc.
### Communication Channels
- **CHANNEL_GROUP_SAY**: Group say channel (11)
- **CHANNEL_GROUP_CHAT**: Group chat channel (31)
- **CHANNEL_RAID_SAY**: Raid say channel (35)
## Thread Safety
All group operations are thread-safe using appropriate synchronization:
- **RWMutex** for read-heavy operations (member lists, group lookups)
- **Atomic operations** for simple counters and flags
- **Channel-based communication** for message and update processing
- **Proper lock ordering** to prevent deadlocks
- **Background goroutines** for periodic processing
## Integration with Other Systems
The groups system integrates with:
- **Entity System** - Groups work with any entity (players, NPCs, bots)
- **Player System** - Player-specific group functionality and client handling
- **Quest System** - Quest sharing within groups
- **Spell System** - Group buffs and spell coordination
- **Zone System** - Cross-zone group management
- **Chat System** - Group communication channels
- **Database System** - Group persistence and recovery
- **Network System** - Cross-server group coordination
## Performance Considerations
- **Efficient member tracking** with hash maps for O(1) lookups
- **Batched message processing** to reduce overhead
- **Background processing** for periodic updates and cleanup
- **Memory-efficient data structures** with proper cleanup
- **Statistics collection** with minimal performance impact
- **Channel buffering** to prevent blocking on message queues
## Migration from C++
This Go implementation maintains compatibility with the original C++ EQ2EMu groups system while providing:
- **Modern concurrency** with goroutines and channels
- **Better error handling** with Go's error interface
- **Cleaner architecture** with interface-based design
- **Improved maintainability** with package organization
- **Enhanced testing** capabilities
- **Type safety** with Go's type system
## TODO Items
The conversion includes TODO comments marking areas for future implementation:
- **Quest sharing integration** with the quest system
- **Complete spell/buff integration** for group buffs
- **Advanced packet handling** for all client communication
- **Complete database schema** implementation
- **Cross-server peer management** completion
- **Bot and NPC integration** improvements
- **Advanced raid mechanics** (raid loot, raid targeting)
- **Group PvP functionality** implementation
- **Performance optimizations** for large-scale deployments
## Usage Examples
See the code examples throughout this documentation for detailed usage patterns. The system is designed to be used alongside the existing EQ2Go server infrastructure with proper initialization and configuration.
The groups system provides a solid foundation for MMO group mechanics while maintaining the flexibility to extend and customize behavior through the comprehensive interface system.

View File

@ -1,624 +0,0 @@
package groups
import (
"fmt"
"math/rand"
"sync/atomic"
"testing"
"time"
)
// Mock implementations for benchmarking use the existing mock entities from groups_test.go
// Helper functions for creating test data
// createTestEntity creates a mock entity for benchmarking
func createTestEntity(id int32, name string, isPlayer bool) *mockEntity {
entity := createMockEntity(id, name, isPlayer)
// Randomize some properties for more realistic benchmarking
entity.level = int8(rand.Intn(80) + 1)
entity.class = int8(rand.Intn(25) + 1)
entity.race = int8(rand.Intn(18) + 1)
entity.hp = int32(rand.Intn(5000) + 1000)
entity.maxHP = int32(rand.Intn(5000) + 1000)
entity.power = int32(rand.Intn(3000) + 500)
entity.maxPower = int32(rand.Intn(3000) + 500)
entity.isBot = !isPlayer && rand.Intn(2) == 1
entity.isNPC = !isPlayer && rand.Intn(2) == 0
// Use fewer zones to reduce indexing complexity in benchmarks
entity.zone = &mockZone{
zoneID: int32(rand.Intn(5) + 1),
instanceID: int32(rand.Intn(3)),
zoneName: fmt.Sprintf("Zone %d", rand.Intn(5)+1),
}
return entity
}
// createTestGroup creates a group with test data for benchmarking
func createTestGroup(b *testing.B, groupID int32, memberCount int) *Group {
b.Helper()
group := NewGroup(groupID, nil, nil)
// Add test members
for i := 0; i < memberCount; i++ {
entity := createTestEntity(
int32(i+1),
fmt.Sprintf("Player%d", i+1),
true,
)
isLeader := (i == 0)
err := group.AddMember(entity, isLeader)
if err != nil {
b.Fatalf("Failed to add member to group: %v", err)
}
}
return group
}
// BenchmarkGroupCreation measures group creation performance
func BenchmarkGroupCreation(b *testing.B) {
b.Run("NewGroup", func(b *testing.B) {
for i := 0; i < b.N; i++ {
group := NewGroup(int32(i+1), nil, nil)
group.Disband() // Clean up background goroutine
}
})
b.Run("NewGroupWithOptions", func(b *testing.B) {
options := DefaultGroupOptions()
for i := 0; i < b.N; i++ {
group := NewGroup(int32(i+1), &options, nil)
group.Disband() // Clean up background goroutine
}
})
b.Run("NewGroupParallel", func(b *testing.B) {
var idCounter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
id := atomic.AddInt64(&idCounter, 1)
group := NewGroup(int32(id), nil, nil)
group.Disband() // Clean up background goroutine
}
})
})
}
// BenchmarkGroupMemberOperations measures member management performance
func BenchmarkGroupMemberOperations(b *testing.B) {
group := createTestGroup(b, 1001, 3)
defer group.Disband() // Clean up background goroutine
b.Run("AddMember", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Create a new group for each iteration to avoid full group
testGroup := NewGroup(int32(i+1000), nil, nil)
entity := createTestEntity(int32(i+1), fmt.Sprintf("BenchPlayer%d", i), true)
testGroup.AddMember(entity, false)
testGroup.Disband() // Clean up background goroutine
}
})
b.Run("GetSize", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = group.GetSize()
}
})
})
b.Run("GetMembers", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = group.GetMembers()
}
})
b.Run("GetLeaderName", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = group.GetLeaderName()
}
})
})
b.Run("UpdateGroupMemberInfo", func(b *testing.B) {
members := group.GetMembers()
if len(members) == 0 {
b.Skip("No members to update")
}
for i := 0; i < b.N; i++ {
member := members[i%len(members)]
if member.Member != nil {
group.UpdateGroupMemberInfo(member.Member, false)
}
}
})
}
// BenchmarkGroupOptions measures group options performance
func BenchmarkGroupOptions(b *testing.B) {
group := createTestGroup(b, 1001, 3)
defer group.Disband() // Clean up background goroutine
b.Run("GetGroupOptions", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = group.GetGroupOptions()
}
})
})
b.Run("SetGroupOptions", func(b *testing.B) {
options := DefaultGroupOptions()
for i := 0; i < b.N; i++ {
options.LootMethod = int8(i % 4)
group.SetGroupOptions(&options)
}
})
b.Run("GetLastLooterIndex", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = group.GetLastLooterIndex()
}
})
})
b.Run("SetNextLooterIndex", func(b *testing.B) {
for i := 0; i < b.N; i++ {
group.SetNextLooterIndex(int8(i % 6))
}
})
}
// BenchmarkGroupRaidOperations measures raid functionality performance
func BenchmarkGroupRaidOperations(b *testing.B) {
group := createTestGroup(b, 1001, 6)
defer group.Disband() // Clean up background goroutine
// Setup some raid groups
raidGroups := []int32{1001, 1002, 1003, 1004}
group.ReplaceRaidGroups(raidGroups)
b.Run("GetRaidGroups", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = group.GetRaidGroups()
}
})
})
b.Run("IsGroupRaid", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = group.IsGroupRaid()
}
})
})
b.Run("IsInRaidGroup", func(b *testing.B) {
for i := 0; i < b.N; i++ {
targetID := int32((i % 4) + 1001)
_ = group.IsInRaidGroup(targetID, false)
}
})
b.Run("AddGroupToRaid", func(b *testing.B) {
for i := 0; i < b.N; i++ {
// Cycle through a limited set of group IDs to avoid infinite growth
groupID := int32(2000 + (i % 10))
group.AddGroupToRaid(groupID)
}
})
b.Run("ReplaceRaidGroups", func(b *testing.B) {
newGroups := []int32{2001, 2002, 2003}
for i := 0; i < b.N; i++ {
group.ReplaceRaidGroups(newGroups)
}
})
b.Run("ClearGroupRaid", func(b *testing.B) {
for i := 0; i < b.N; i++ {
group.ClearGroupRaid()
// Re-add groups for next iteration
group.ReplaceRaidGroups(raidGroups)
}
})
}
// BenchmarkGroupMessaging measures messaging performance
func BenchmarkGroupMessaging(b *testing.B) {
group := createTestGroup(b, 1001, 6)
defer group.Disband() // Clean up background goroutine
b.Run("SimpleGroupMessage", func(b *testing.B) {
for i := 0; i < b.N; i++ {
group.SimpleGroupMessage(fmt.Sprintf("Benchmark message %d", i))
}
})
b.Run("SendGroupMessage", func(b *testing.B) {
for i := 0; i < b.N; i++ {
group.SendGroupMessage(GROUP_MESSAGE_TYPE_SYSTEM, fmt.Sprintf("System message %d", i))
}
})
b.Run("GroupChatMessageFromName", func(b *testing.B) {
for i := 0; i < b.N; i++ {
group.GroupChatMessageFromName(
fmt.Sprintf("Player%d", i%6+1),
0,
fmt.Sprintf("Chat message %d", i),
CHANNEL_GROUP_CHAT,
)
}
})
}
// BenchmarkGroupState measures group state operations
func BenchmarkGroupState(b *testing.B) {
group := createTestGroup(b, 1001, 6)
defer group.Disband() // Clean up background goroutine
b.Run("GetID", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = group.GetID()
}
})
})
b.Run("IsDisbanded", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = group.IsDisbanded()
}
})
})
b.Run("GetCreatedTime", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = group.GetCreatedTime()
}
})
})
b.Run("GetLastActivity", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = group.GetLastActivity()
}
})
})
}
// BenchmarkMasterListOperations measures master list performance
func BenchmarkMasterListOperations(b *testing.B) {
ml := NewMasterList()
// Pre-populate with groups - reduced from 1000 to avoid goroutine exhaustion
const numGroups = 100
groups := make([]*Group, numGroups)
b.StopTimer()
for i := 0; i < numGroups; i++ {
groups[i] = createTestGroup(b, int32(i+1), rand.Intn(6)+1)
ml.AddGroup(groups[i])
}
// Cleanup all groups when benchmark is done
defer func() {
for _, group := range groups {
if group != nil {
group.Disband()
}
}
}()
b.StartTimer()
b.Run("GetGroup", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
id := int32(rand.Intn(numGroups) + 1)
_ = ml.GetGroup(id)
}
})
})
b.Run("AddGroup", func(b *testing.B) {
startID := int32(numGroups + 1)
var addedGroups []*Group
b.ResetTimer()
for i := 0; i < b.N; i++ {
group := createTestGroup(b, startID+int32(i), 3)
ml.AddGroup(group)
addedGroups = append(addedGroups, group)
}
// Immediate cleanup to prevent goroutine exhaustion
b.StopTimer()
for _, group := range addedGroups {
if group != nil {
group.Disband()
}
}
b.StartTimer()
})
b.Run("GetAllGroups", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = ml.GetAllGroups()
}
})
b.Run("GetActiveGroups", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = ml.GetActiveGroups()
}
})
b.Run("GetGroupsByZone", func(b *testing.B) {
for i := 0; i < b.N; i++ {
zoneID := int32(rand.Intn(5) + 1) // Match reduced zone range
_ = ml.GetGroupsByZone(zoneID)
}
})
b.Run("GetGroupsBySize", func(b *testing.B) {
for i := 0; i < b.N; i++ {
size := int32(rand.Intn(6) + 1)
_ = ml.GetGroupsBySize(size)
}
})
b.Run("GetRaidGroups", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = ml.GetRaidGroups()
}
})
b.Run("GetGroupStatistics", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = ml.GetGroupStatistics()
}
})
}
// BenchmarkManagerOperations measures manager performance
func BenchmarkManagerOperations(b *testing.B) {
config := GroupManagerConfig{
MaxGroups: 1000,
InviteTimeout: 30 * time.Second,
UpdateInterval: 1 * time.Second,
BuffUpdateInterval: 5 * time.Second,
EnableStatistics: true,
}
manager := NewManager(config, nil)
// Pre-populate with groups
b.StopTimer()
for i := 0; i < 100; i++ {
leader := createTestEntity(int32(i+1), fmt.Sprintf("Leader%d", i+1), true)
manager.NewGroup(leader, nil, 0)
}
b.StartTimer()
b.Run("NewGroup", func(b *testing.B) {
startID := int32(1000)
for i := 0; i < b.N; i++ {
leader := createTestEntity(startID+int32(i), fmt.Sprintf("BenchLeader%d", i), true)
_, err := manager.NewGroup(leader, nil, 0)
if err != nil {
b.Fatalf("Failed to create group: %v", err)
}
}
})
b.Run("GetGroup", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
groupID := int32(rand.Intn(100) + 1)
_ = manager.GetGroup(groupID)
}
})
})
b.Run("IsGroupIDValid", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
groupID := int32(rand.Intn(100) + 1)
_ = manager.IsGroupIDValid(groupID)
}
})
})
b.Run("GetGroupCount", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = manager.GetGroupCount()
}
})
})
b.Run("GetAllGroups", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = manager.GetAllGroups()
}
})
b.Run("GetStats", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = manager.GetStats()
}
})
})
}
// BenchmarkInviteSystem measures invitation system performance
func BenchmarkInviteSystem(b *testing.B) {
config := GroupManagerConfig{
InviteTimeout: 30 * time.Second,
EnableStatistics: true,
}
manager := NewManager(config, nil)
b.Run("AddInvite", func(b *testing.B) {
for i := 0; i < b.N; i++ {
leader := createTestEntity(int32(i+1), fmt.Sprintf("Leader%d", i+1), true)
member := createTestEntity(int32(i+1001), fmt.Sprintf("Member%d", i+1), true)
manager.AddInvite(leader, member)
}
})
b.Run("HasPendingInvite", func(b *testing.B) {
// Add some invites first
for i := 0; i < 100; i++ {
leader := createTestEntity(int32(i+1), fmt.Sprintf("TestLeader%d", i+1), true)
member := createTestEntity(int32(i+2001), fmt.Sprintf("TestMember%d", i+1), true)
manager.AddInvite(leader, member)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
member := createTestEntity(int32(rand.Intn(100)+2001), fmt.Sprintf("TestMember%d", rand.Intn(100)+1), true)
_ = manager.HasPendingInvite(member)
}
})
})
b.Run("Invite", func(b *testing.B) {
for i := 0; i < b.N; i++ {
leader := createTestEntity(int32(i+3001), fmt.Sprintf("InviteLeader%d", i+1), true)
member := createTestEntity(int32(i+4001), fmt.Sprintf("InviteMember%d", i+1), true)
_ = manager.Invite(leader, member)
}
})
b.Run("DeclineInvite", func(b *testing.B) {
// Add invites to decline
for i := 0; i < b.N; i++ {
leader := createTestEntity(int32(i+5001), fmt.Sprintf("DeclineLeader%d", i+1), true)
member := createTestEntity(int32(i+6001), fmt.Sprintf("DeclineMember%d", i+1), true)
manager.AddInvite(leader, member)
manager.DeclineInvite(member)
}
})
}
// BenchmarkConcurrentOperations measures concurrent access performance
func BenchmarkConcurrentOperations(b *testing.B) {
config := GroupManagerConfig{
MaxGroups: 1000,
EnableStatistics: true,
}
manager := NewManager(config, nil)
// Pre-populate
for i := 0; i < 50; i++ {
leader := createTestEntity(int32(i+1), fmt.Sprintf("ConcurrentLeader%d", i+1), true)
manager.NewGroup(leader, nil, 0)
}
b.Run("ConcurrentGroupAccess", func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
groupID := int32(rand.Intn(50) + 1)
switch rand.Intn(4) {
case 0:
_ = manager.GetGroup(groupID)
case 1:
_ = manager.GetGroupSize(groupID)
case 2:
_ = manager.IsGroupIDValid(groupID)
case 3:
member := createTestEntity(int32(rand.Intn(1000)+10000), "ConcurrentMember", true)
manager.AddGroupMember(groupID, member, false)
}
}
})
})
b.Run("ConcurrentInviteOperations", func(b *testing.B) {
var counter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
i := atomic.AddInt64(&counter, 1)
leader := createTestEntity(int32(i+20000), fmt.Sprintf("ConcurrentInviteLeader%d", i), true)
member := createTestEntity(int32(i+30000), fmt.Sprintf("ConcurrentInviteMember%d", i), true)
switch rand.Intn(3) {
case 0:
manager.AddInvite(leader, member)
case 1:
_ = manager.HasPendingInvite(member)
case 2:
manager.DeclineInvite(member)
}
}
})
})
}
// BenchmarkMemoryAllocation measures memory allocation patterns
func BenchmarkMemoryAllocation(b *testing.B) {
b.Run("GroupAllocation", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
group := NewGroup(int32(i+1), nil, nil)
group.Disband() // Clean up background goroutine
}
})
b.Run("MasterListAllocation", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
ml := NewMasterList()
_ = ml
}
})
b.Run("ManagerAllocation", func(b *testing.B) {
config := GroupManagerConfig{}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
manager := NewManager(config, nil)
_ = manager
}
})
b.Run("GroupMemberInfoAllocation", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
gmi := &GroupMemberInfo{
GroupID: int32(i + 1),
Name: fmt.Sprintf("Member%d", i+1),
Zone: "TestZone",
HPCurrent: 1000,
HPMax: 1000,
PowerCurrent: 500,
PowerMax: 500,
LevelCurrent: 50,
LevelMax: 80,
RaceID: 1,
ClassID: 1,
Leader: false,
IsClient: true,
JoinTime: time.Now(),
LastUpdate: time.Now(),
}
_ = gmi
}
})
}

File diff suppressed because it is too large Load Diff

View File

@ -1,613 +0,0 @@
package groups
import (
"fmt"
"sync"
"testing"
"time"
)
// TestManagerBackground tests background processes in the manager
func TestManagerBackground(t *testing.T) {
config := GroupManagerConfig{
MaxGroups: 100,
MaxRaidGroups: 4,
InviteTimeout: 100 * time.Millisecond, // Short for testing
UpdateInterval: 50 * time.Millisecond,
BuffUpdateInterval: 50 * time.Millisecond,
EnableCrossServer: true,
EnableRaids: true,
EnableQuestSharing: true,
EnableStatistics: true,
}
manager := NewManager(config, nil)
err := manager.Start()
if err != nil {
t.Fatalf("Failed to start manager: %v", err)
}
defer manager.Stop()
// Let background processes run
time.Sleep(200 * time.Millisecond)
// Verify manager is running
stats := manager.GetStats()
if stats.ActiveGroups != 0 {
t.Errorf("Expected 0 active groups, got %d", stats.ActiveGroups)
}
}
// TestManagerGroupLifecycle tests complete group lifecycle through manager
func TestManagerGroupLifecycle(t *testing.T) {
config := GroupManagerConfig{
MaxGroups: 100,
MaxRaidGroups: 4,
InviteTimeout: 1 * time.Second,
UpdateInterval: 0, // Disable for testing
BuffUpdateInterval: 0, // Disable for testing
EnableStatistics: false,
}
manager := NewManager(config, nil)
err := manager.Start()
if err != nil {
t.Fatalf("Failed to start manager: %v", err)
}
defer manager.Stop()
// Create entities
leader := createMockEntity(1, "Leader", true)
member1 := createMockEntity(2, "Member1", true)
member2 := createMockEntity(3, "Member2", true)
// Test group creation
groupID, err := manager.NewGroup(leader, nil, 0)
if err != nil {
t.Fatalf("Failed to create group: %v", err)
}
// Verify group was created
if !manager.IsGroupIDValid(groupID) {
t.Error("Group ID should be valid after creation")
}
// Test adding members
err = manager.AddGroupMember(groupID, member1, false)
if err != nil {
t.Errorf("Failed to add member1: %v", err)
}
err = manager.AddGroupMember(groupID, member2, false)
if err != nil {
t.Errorf("Failed to add member2: %v", err)
}
// Verify group size
size := manager.GetGroupSize(groupID)
if size != 3 {
t.Errorf("Expected group size 3, got %d", size)
}
// Test leadership transfer
if !manager.MakeLeader(groupID, member1) {
t.Error("Failed to make member1 leader")
}
// Verify new leader
leader2 := manager.GetGroupLeader(groupID)
if leader2 != member1 {
t.Error("Leader should be member1 after transfer")
}
// Test member removal
err = manager.RemoveGroupMember(groupID, member2)
if err != nil {
t.Errorf("Failed to remove member2: %v", err)
}
// Verify member was removed
if manager.IsInGroup(groupID, member2) {
t.Error("Member2 should not be in group after removal")
}
// Test group disbanding
err = manager.RemoveGroup(groupID)
if err != nil {
t.Errorf("Failed to remove group: %v", err)
}
// Verify group was removed
if manager.IsGroupIDValid(groupID) {
t.Error("Group should not be valid after removal")
}
}
// TestManagerInviteSystem tests the invitation system
func TestManagerInviteSystem(t *testing.T) {
config := GroupManagerConfig{
MaxGroups: 100,
MaxRaidGroups: 4,
InviteTimeout: 200 * time.Millisecond,
UpdateInterval: 0,
BuffUpdateInterval: 0,
EnableStatistics: false,
}
manager := NewManager(config, nil)
err := manager.Start()
if err != nil {
t.Fatalf("Failed to start manager: %v", err)
}
defer manager.Stop()
leader := createMockEntity(1, "Leader", true)
member := createMockEntity(2, "Member", true)
// Create group
groupID, _ := manager.NewGroup(leader, nil, 0)
// Test sending invitation
result := manager.Invite(leader, member)
if result != GROUP_INVITE_SUCCESS {
t.Errorf("Expected invite success, got %d", result)
}
// Verify pending invite
inviterName := manager.HasPendingInvite(member)
if inviterName != "Leader" {
t.Errorf("Expected inviter name 'Leader', got '%s'", inviterName)
}
// Test duplicate invitation
result = manager.Invite(leader, member)
if result != GROUP_INVITE_ALREADY_HAS_INVITE {
t.Errorf("Expected already has invite error, got %d", result)
}
// Test declining invitation
manager.DeclineInvite(member)
inviterName = manager.HasPendingInvite(member)
if inviterName != "" {
t.Error("Should have no pending invite after decline")
}
// Test invitation expiration
manager.Invite(leader, member)
time.Sleep(300 * time.Millisecond)
// Manually trigger cleanup
manager.cleanupExpiredInvites()
inviterName = manager.HasPendingInvite(member)
if inviterName != "" {
t.Error("Invite should have expired")
}
// Clean up
manager.RemoveGroup(groupID)
}
// TestManagerRaidOperations tests raid functionality
func TestManagerRaidOperations(t *testing.T) {
config := GroupManagerConfig{
MaxGroups: 100,
MaxRaidGroups: 4,
InviteTimeout: 1 * time.Second,
UpdateInterval: 0,
BuffUpdateInterval: 0,
EnableRaids: true,
EnableStatistics: false,
}
manager := NewManager(config, nil)
err := manager.Start()
if err != nil {
t.Fatalf("Failed to start manager: %v", err)
}
defer manager.Stop()
// Create multiple groups
groups := make([]int32, 4)
for i := range 4 {
leader := createMockEntity(int32(i*10+1), fmt.Sprintf("Leader%d", i), true)
groupID, err := manager.NewGroup(leader, nil, 0)
if err != nil {
t.Fatalf("Failed to create group %d: %v", i, err)
}
groups[i] = groupID
// Add members to each group
for j := range 3 {
member := createMockEntity(int32(i*10+j+2), fmt.Sprintf("Member%d_%d", i, j), true)
manager.AddGroupMember(groupID, member, false)
}
}
// Form raid
manager.ReplaceRaidGroups(groups[0], groups)
manager.ReplaceRaidGroups(groups[1], groups)
manager.ReplaceRaidGroups(groups[2], groups)
manager.ReplaceRaidGroups(groups[3], groups)
// Verify raid status
for _, groupID := range groups {
if !manager.IsInRaidGroup(groupID, groupID, false) {
t.Errorf("Group %d should be in raid", groupID)
}
}
// Test raid group lookup
if !manager.IsInRaidGroup(groups[0], groups[3], false) {
t.Error("Groups should be in same raid")
}
// Clear raid
for _, groupID := range groups {
manager.ClearGroupRaid(groupID)
}
// Verify raid cleared
for _, groupID := range groups {
if manager.IsInRaidGroup(groupID, groupID, false) {
t.Errorf("Group %d should not be in raid after clear", groupID)
}
}
// Clean up
for _, groupID := range groups {
manager.RemoveGroup(groupID)
}
}
// TestManagerConcurrentOperations tests thread safety
func TestManagerConcurrentOperations(t *testing.T) {
config := GroupManagerConfig{
MaxGroups: 1000,
MaxRaidGroups: 4,
InviteTimeout: 1 * time.Second,
UpdateInterval: 0,
BuffUpdateInterval: 0,
EnableStatistics: false,
}
manager := NewManager(config, nil)
err := manager.Start()
if err != nil {
t.Fatalf("Failed to start manager: %v", err)
}
defer manager.Stop()
const numGoroutines = 20
const operationsPerGoroutine = 50
var wg sync.WaitGroup
// Concurrent group creation and removal
wg.Add(numGoroutines)
for i := range numGoroutines {
go func(id int) {
defer wg.Done()
for j := range operationsPerGoroutine {
leader := createMockEntity(int32(id*1000+j), fmt.Sprintf("Leader%d_%d", id, j), true)
// Create group
groupID, err := manager.NewGroup(leader, nil, 0)
if err != nil {
continue
}
// Add members
for k := range 3 {
member := createMockEntity(int32(id*1000+j*10+k), fmt.Sprintf("Member%d_%d_%d", id, j, k), true)
manager.AddGroupMember(groupID, member, false)
}
// Sometimes transfer leadership
if j%3 == 0 {
members := manager.GetGroup(groupID).GetMembers()
if len(members) > 1 {
manager.MakeLeader(groupID, members[1].Member)
}
}
// Sometimes update options
if j%2 == 0 {
options := DefaultGroupOptions()
options.LootMethod = int8(j % 4)
manager.SetGroupOptions(groupID, &options)
}
// Remove group
manager.RemoveGroup(groupID)
}
}(i)
}
wg.Wait()
// Verify no groups remain
count := manager.GetGroupCount()
if count != 0 {
t.Errorf("Expected 0 groups after cleanup, got %d", count)
}
}
// TestManagerStatistics tests statistics tracking
func TestManagerStatistics(t *testing.T) {
config := GroupManagerConfig{
MaxGroups: 100,
MaxRaidGroups: 4,
InviteTimeout: 1 * time.Second,
UpdateInterval: 0,
BuffUpdateInterval: 0,
EnableStatistics: true,
}
manager := NewManager(config, nil)
err := manager.Start()
if err != nil {
t.Fatalf("Failed to start manager: %v", err)
}
defer manager.Stop()
// Initial stats
stats := manager.GetStats()
if stats.ActiveGroups != 0 {
t.Errorf("Expected 0 active groups initially, got %d", stats.ActiveGroups)
}
// Create groups and verify stats update
leader1 := createMockEntity(1, "Leader1", true)
groupID1, _ := manager.NewGroup(leader1, nil, 0)
// Background stats update is disabled, so we'll check TotalGroups which is updated immediately
stats = manager.GetStats()
// ActiveGroups is only updated by background stats loop which is disabled
// So we'll skip the ActiveGroups check
if stats.TotalGroups != 1 {
t.Errorf("Expected 1 total group created, got %d", stats.TotalGroups)
}
// Add members
member1 := createMockEntity(2, "Member1", true)
manager.AddGroupMember(groupID1, member1, false)
stats = manager.GetStats()
// Stats tracking for members is not implemented in GroupManagerStats
// so we'll skip this check
// Send invitations
member2 := createMockEntity(3, "Member2", true)
manager.Invite(leader1, member2)
stats = manager.GetStats()
if stats.TotalInvites != 1 {
t.Errorf("Expected 1 invite sent, got %d", stats.TotalInvites)
}
// Decline invitation
manager.DeclineInvite(member2)
stats = manager.GetStats()
if stats.DeclinedInvites != 1 {
t.Errorf("Expected 1 invite declined, got %d", stats.DeclinedInvites)
}
// Remove group
manager.RemoveGroup(groupID1)
stats = manager.GetStats()
if stats.ActiveGroups != 0 {
t.Errorf("Expected 0 active groups after removal, got %d", stats.ActiveGroups)
}
// GroupManagerStats doesn't track disbanded groups separately
// Only active groups count
}
// TestManagerEventHandlers tests event handling
func TestManagerEventHandlers(t *testing.T) {
config := GroupManagerConfig{
MaxGroups: 100,
MaxRaidGroups: 4,
InviteTimeout: 1 * time.Second,
UpdateInterval: 0,
BuffUpdateInterval: 0,
EnableStatistics: false,
}
manager := NewManager(config, nil)
// Track events
events := make([]string, 0)
var eventsMutex sync.Mutex
// Add event handler
handler := &mockEventHandler{
onGroupCreated: func(group *Group, leader Entity) {
eventsMutex.Lock()
events = append(events, fmt.Sprintf("created:%d", group.GetID()))
eventsMutex.Unlock()
},
onGroupDisbanded: func(groupID int32) {
eventsMutex.Lock()
events = append(events, fmt.Sprintf("disbanded:%d", groupID))
eventsMutex.Unlock()
},
onMemberJoined: func(groupID int32, member *GroupMemberInfo) {
eventsMutex.Lock()
events = append(events, fmt.Sprintf("joined:%d:%s", groupID, member.Name))
eventsMutex.Unlock()
},
onMemberLeft: func(groupID int32, memberName string) {
eventsMutex.Lock()
events = append(events, fmt.Sprintf("left:%d:%s", groupID, memberName))
eventsMutex.Unlock()
},
}
manager.AddEventHandler(handler)
err := manager.Start()
if err != nil {
t.Fatalf("Failed to start manager: %v", err)
}
defer manager.Stop()
// Create group
leader := createMockEntity(1, "Leader", true)
groupID, _ := manager.NewGroup(leader, nil, 0)
// Add member
member := createMockEntity(2, "Member", true)
manager.AddGroupMember(groupID, member, false)
// Remove member
manager.RemoveGroupMember(groupID, member)
// Disband group
manager.RemoveGroup(groupID)
// Give events time to process
time.Sleep(10 * time.Millisecond)
// Verify events
eventsMutex.Lock()
defer eventsMutex.Unlock()
// Only group created and disbanded events are currently fired
// Member join/leave events are not implemented in the manager
expectedEvents := []string{
fmt.Sprintf("created:%d", groupID),
fmt.Sprintf("disbanded:%d", groupID),
}
if len(events) != len(expectedEvents) {
t.Logf("Note: Member join/leave events are not implemented")
t.Logf("Expected %d events, got %d", len(expectedEvents), len(events))
t.Logf("Events: %v", events)
}
for i, expected := range expectedEvents {
if i < len(events) && events[i] != expected {
t.Errorf("Event %d: expected '%s', got '%s'", i, expected, events[i])
}
}
}
// Mock event handler for testing
type mockEventHandler struct {
onGroupCreated func(group *Group, leader Entity)
onGroupDisbanded func(groupID int32)
onMemberJoined func(groupID int32, member *GroupMemberInfo)
onMemberLeft func(groupID int32, memberName string)
onLeaderChanged func(groupID int32, newLeader Entity)
onOptionsChanged func(groupID int32, options *GroupOptions)
onRaidFormed func(raidGroups []int32)
onRaidDisbanded func(raidGroups []int32)
}
func (m *mockEventHandler) OnGroupCreated(group *Group, leader Entity) error {
if m.onGroupCreated != nil {
m.onGroupCreated(group, leader)
}
return nil
}
func (m *mockEventHandler) OnGroupDisbanded(group *Group) error {
if m.onGroupDisbanded != nil {
m.onGroupDisbanded(group.GetID())
}
return nil
}
func (m *mockEventHandler) OnGroupMemberJoined(group *Group, member Entity) error {
if m.onMemberJoined != nil {
// Find the member info
for _, gmi := range group.GetMembers() {
if gmi.Member == member {
m.onMemberJoined(group.GetID(), gmi)
break
}
}
}
return nil
}
func (m *mockEventHandler) OnGroupMemberLeft(group *Group, member Entity) error {
if m.onMemberLeft != nil {
m.onMemberLeft(group.GetID(), member.GetName())
}
return nil
}
func (m *mockEventHandler) OnGroupLeaderChanged(group *Group, oldLeader, newLeader Entity) error {
if m.onLeaderChanged != nil {
m.onLeaderChanged(group.GetID(), newLeader)
}
return nil
}
func (m *mockEventHandler) OnGroupInviteSent(leader, member Entity) error {
return nil
}
func (m *mockEventHandler) OnGroupInviteAccepted(leader, member Entity, groupID int32) error {
return nil
}
func (m *mockEventHandler) OnGroupInviteDeclined(leader, member Entity) error {
return nil
}
func (m *mockEventHandler) OnGroupInviteExpired(leader, member Entity) error {
return nil
}
func (m *mockEventHandler) OnRaidFormed(groups []*Group) error {
if m.onRaidFormed != nil {
ids := make([]int32, len(groups))
for i, g := range groups {
ids[i] = g.GetID()
}
m.onRaidFormed(ids)
}
return nil
}
func (m *mockEventHandler) OnRaidDisbanded(groups []*Group) error {
if m.onRaidDisbanded != nil {
ids := make([]int32, len(groups))
for i, g := range groups {
ids[i] = g.GetID()
}
m.onRaidDisbanded(ids)
}
return nil
}
func (m *mockEventHandler) OnRaidInviteSent(leaderGroup *Group, targetGroup *Group) error {
return nil
}
func (m *mockEventHandler) OnRaidInviteAccepted(leaderGroup *Group, targetGroup *Group) error {
return nil
}
func (m *mockEventHandler) OnRaidInviteDeclined(leaderGroup *Group, targetGroup *Group) error {
return nil
}
func (m *mockEventHandler) OnGroupMessage(group *Group, from Entity, message string, channel int16) error {
return nil
}
func (m *mockEventHandler) OnGroupOptionsChanged(group *Group, oldOptions, newOptions *GroupOptions) error {
if m.onOptionsChanged != nil {
m.onOptionsChanged(group.GetID(), newOptions)
}
return nil
}
func (m *mockEventHandler) OnGroupMemberUpdate(group *Group, member *GroupMemberInfo) error {
return nil
}

View File

@ -1,545 +0,0 @@
package guilds
import (
"fmt"
"testing"
"time"
"eq2emu/internal/database"
)
// TestNewGuild tests guild creation with default values
func TestNewGuild(t *testing.T) {
// Create a mock database (we'll use nil since these tests don't use database)
var db *database.Database
guild := New(db)
// Test initial state
if guild.GetLevel() != 1 {
t.Errorf("Expected initial level 1, got %d", guild.GetLevel())
}
if guild.GetEXPCurrent() != 111 {
t.Errorf("Expected initial expCurrent 111, got %d", guild.GetEXPCurrent())
}
if guild.GetEXPToNextLevel() != 2521 {
t.Errorf("Expected initial expToNextLevel 2521, got %d", guild.GetEXPToNextLevel())
}
if guild.GetRecruitingMinLevel() != 1 {
t.Errorf("Expected initial recruitingMinLevel 1, got %d", guild.GetRecruitingMinLevel())
}
if guild.GetRecruitingPlayStyle() != RecruitingPlayStyleNone {
t.Errorf("Expected initial recruitingPlayStyle %d, got %d", RecruitingPlayStyleNone, guild.GetRecruitingPlayStyle())
}
if guild.GetNextEventID() != 1 {
t.Errorf("Expected initial nextEventID 1, got %d", guild.GetNextEventID())
}
// Test recruiting flags are initialized
if guild.GetRecruitingFlag(RecruitingFlagTraining) != 0 {
t.Error("Training recruiting flag should be initialized to 0")
}
if guild.GetRecruitingFlag(RecruitingFlagFighters) != 0 {
t.Error("Fighters recruiting flag should be initialized to 0")
}
// Test description tags are initialized
if guild.GetRecruitingDescTag(0) != RecruitingDescTagNone {
t.Error("Description tag 0 should be initialized to None")
}
// Test default rank names are set
if guild.GetRankName(RankLeader) != "Leader" {
t.Errorf("Expected leader rank name 'Leader', got '%s'", guild.GetRankName(RankLeader))
}
if guild.GetRankName(RankRecruit) != "Recruit" {
t.Errorf("Expected recruit rank name 'Recruit', got '%s'", guild.GetRankName(RankRecruit))
}
}
// TestGuildBasicOperations tests basic guild getter/setter operations
func TestGuildBasicOperations(t *testing.T) {
var db *database.Database
guild := New(db)
// Test ID operations
testID := int32(12345)
guild.SetID(testID)
if guild.GetID() != testID {
t.Errorf("Expected ID %d, got %d", testID, guild.GetID())
}
// Test name operations
testName := "Test Guild"
guild.SetName(testName, false)
if guild.GetName() != testName {
t.Errorf("Expected name '%s', got '%s'", testName, guild.GetName())
}
// Test level operations
testLevel := int8(10)
guild.SetLevel(testLevel, false)
if guild.GetLevel() != testLevel {
t.Errorf("Expected level %d, got %d", testLevel, guild.GetLevel())
}
// Test MOTD operations
testMOTD := "Welcome to our guild!"
guild.SetMOTD(testMOTD, false)
if guild.GetMOTD() != testMOTD {
t.Errorf("Expected MOTD '%s', got '%s'", testMOTD, guild.GetMOTD())
}
// Test formed date operations
testDate := time.Now().Add(-24 * time.Hour)
guild.SetFormedDate(testDate)
if !guild.GetFormedDate().Equal(testDate) {
t.Errorf("Expected formed date %v, got %v", testDate, guild.GetFormedDate())
}
// Test experience operations
testExpCurrent := int64(5000)
testExpNext := int64(10000)
guild.SetEXPCurrent(testExpCurrent, false)
guild.SetEXPToNextLevel(testExpNext, false)
current := guild.GetEXPCurrent()
next := guild.GetEXPToNextLevel()
if current != testExpCurrent {
t.Errorf("Expected current exp %d, got %d", testExpCurrent, current)
}
if next != testExpNext {
t.Errorf("Expected next level exp %d, got %d", testExpNext, next)
}
}
// TestGuildMemberOperations tests guild member management
func TestGuildMemberOperations(t *testing.T) {
var db *database.Database
guild := New(db)
guild.SetID(1)
// Test adding members using the actual method
characterID1 := int32(1)
characterID2 := int32(2)
inviterName := "TestInviter"
joinDate := time.Now()
// Add first member
success := guild.AddNewGuildMember(characterID1, inviterName, joinDate, RankRecruit)
if !success {
t.Error("Should be able to add first member")
}
// Add second member
success = guild.AddNewGuildMember(characterID2, inviterName, joinDate, RankMember)
if !success {
t.Error("Should be able to add second member")
}
// Test getting member by ID
member1 := guild.GetGuildMember(characterID1)
if member1 == nil {
t.Error("Should be able to retrieve member by ID")
}
if member1.CharacterID != characterID1 {
t.Errorf("Expected member ID %d, got %d", characterID1, member1.CharacterID)
}
// Test getting all members
allMembers := guild.GetAllMembers()
if len(allMembers) != 2 {
t.Errorf("Expected 2 members, got %d", len(allMembers))
}
// Test removing member
guild.RemoveGuildMember(characterID1, false)
member1After := guild.GetGuildMember(characterID1)
if member1After != nil {
t.Error("Member should be nil after removal")
}
allMembersAfter := guild.GetAllMembers()
if len(allMembersAfter) != 1 {
t.Errorf("Expected 1 member after removal, got %d", len(allMembersAfter))
}
}
// TestGuildEventOperations tests guild event management
func TestGuildEventOperations(t *testing.T) {
var db *database.Database
guild := New(db)
// Test adding events
eventType := int32(EventMemberJoins)
description := "Member joined guild"
eventDate := time.Now()
// Add first event (should get ID 1)
guild.AddNewGuildEvent(eventType, description, eventDate, false)
// Add another event (should get ID 2)
guild.AddNewGuildEvent(EventMemberLeaves, "Member left guild", eventDate, false)
// Test getting next event ID (should be 3 now)
nextID := guild.GetNextEventID()
if nextID != 3 {
t.Errorf("Expected next event ID 3, got %d", nextID)
}
// Test getting specific event (first event should have ID 1)
event := guild.GetGuildEvent(1)
if event == nil {
t.Error("Should be able to retrieve event by ID")
}
if event != nil && event.Description != description {
t.Errorf("Expected event description '%s', got '%s'", description, event.Description)
}
}
// TestGuildRankOperations tests guild rank management
func TestGuildRankOperations(t *testing.T) {
var db *database.Database
guild := New(db)
// Test setting custom rank name
customRankName := "Elite Member"
success := guild.SetRankName(RankMember, customRankName, false)
if !success {
t.Error("Should be able to set rank name")
}
rankName := guild.GetRankName(RankMember)
if rankName != customRankName {
t.Errorf("Expected rank name '%s', got '%s'", customRankName, rankName)
}
// Test getting default rank names
leaderName := guild.GetRankName(RankLeader)
if leaderName != "Leader" {
t.Errorf("Expected leader rank name 'Leader', got '%s'", leaderName)
}
}
// TestGuildRecruitingOperations tests guild recruiting settings
func TestGuildRecruitingOperations(t *testing.T) {
var db *database.Database
guild := New(db)
// Test recruiting descriptions
shortDesc := "Looking for members"
fullDesc := "We are a friendly guild looking for active members"
guild.SetRecruitingShortDesc(shortDesc, false)
guild.SetRecruitingFullDesc(fullDesc, false)
short := guild.GetRecruitingShortDesc()
full := guild.GetRecruitingFullDesc()
if short != shortDesc {
t.Errorf("Expected short description '%s', got '%s'", shortDesc, short)
}
if full != fullDesc {
t.Errorf("Expected full description '%s', got '%s'", fullDesc, full)
}
// Test recruiting settings
minLevel := int8(20)
playStyle := int8(2)
guild.SetRecruitingMinLevel(minLevel, false)
guild.SetRecruitingPlayStyle(playStyle, false)
getMinLevel := guild.GetRecruitingMinLevel()
getPlayStyle := guild.GetRecruitingPlayStyle()
if getMinLevel != minLevel {
t.Errorf("Expected min level %d, got %d", minLevel, getMinLevel)
}
if getPlayStyle != playStyle {
t.Errorf("Expected play style %d, got %d", playStyle, getPlayStyle)
}
// Test recruiting flags
success := guild.SetRecruitingFlag(RecruitingFlagFighters, 1, false)
if !success {
t.Error("Should be able to set recruiting flag")
}
flag := guild.GetRecruitingFlag(RecruitingFlagFighters)
if flag != 1 {
t.Errorf("Expected recruiting flag 1, got %d", flag)
}
// Test recruiting description tags
success = guild.SetRecruitingDescTag(0, RecruitingDescTagRoleplay, false)
if !success {
t.Error("Should be able to set recruiting desc tag")
}
tag := guild.GetRecruitingDescTag(0)
if tag != RecruitingDescTagRoleplay {
t.Errorf("Expected description tag %d, got %d", RecruitingDescTagRoleplay, tag)
}
}
// TestGuildPermissions tests guild permission system
func TestGuildPermissions(t *testing.T) {
var db *database.Database
guild := New(db)
// Test setting permissions
rank := int8(RankMember)
permission := int8(PermissionInvite)
value := int8(1)
success := guild.SetPermission(rank, permission, value, false, false)
if !success {
t.Error("Should be able to set permission")
}
getValue := guild.GetPermission(rank, permission)
if getValue != value {
t.Errorf("Expected permission value %d, got %d", value, getValue)
}
// Test removing permission
success = guild.SetPermission(rank, permission, 0, false, false)
if !success {
t.Error("Should be able to remove permission")
}
getValue = guild.GetPermission(rank, permission)
if getValue != 0 {
t.Errorf("Expected permission value 0 after removal, got %d", getValue)
}
}
// TestGuildSaveFlags tests the save flag system
func TestGuildSaveFlags(t *testing.T) {
var db *database.Database
guild := New(db)
// Test initial state
if guild.GetSaveNeeded() {
t.Error("Guild should not need save initially")
}
// Test marking save needed
guild.SetSaveNeeded(true)
if !guild.GetSaveNeeded() {
t.Error("Guild should need save after marking")
}
// Test clearing save needed
guild.SetSaveNeeded(false)
if guild.GetSaveNeeded() {
t.Error("Guild should not need save after clearing")
}
}
// TestGuildMemberPromotionDemotion tests member rank changes
func TestGuildMemberPromotionDemotion(t *testing.T) {
var db *database.Database
guild := New(db)
guild.SetID(1)
// Add a member
characterID := int32(1)
inviterName := "TestInviter"
joinDate := time.Now()
success := guild.AddNewGuildMember(characterID, inviterName, joinDate, RankRecruit)
if !success {
t.Error("Should be able to add member")
}
// Get member and check initial rank
member := guild.GetGuildMember(characterID)
if member == nil {
t.Fatal("Member should exist")
}
if member.Rank != RankRecruit {
t.Errorf("Expected initial rank %d, got %d", RankRecruit, member.Rank)
}
// Test promotion
promoterName := "TestPromoter"
success = guild.PromoteGuildMember(characterID, promoterName, false)
if !success {
t.Error("Should be able to promote member")
}
member = guild.GetGuildMember(characterID)
if member.Rank != RankInitiate {
t.Errorf("Expected rank after promotion %d, got %d", RankInitiate, member.Rank)
}
// Test demotion
demoterName := "TestDemoter"
success = guild.DemoteGuildMember(characterID, demoterName, false)
if !success {
t.Error("Should be able to demote member")
}
member = guild.GetGuildMember(characterID)
if member.Rank != RankRecruit {
t.Errorf("Expected rank after demotion %d, got %d", RankRecruit, member.Rank)
}
}
// TestGuildPointsSystem tests the guild points system
func TestGuildPointsSystem(t *testing.T) {
var db *database.Database
guild := New(db)
guild.SetID(1)
// Add a member
characterID := int32(1)
inviterName := "TestInviter"
joinDate := time.Now()
success := guild.AddNewGuildMember(characterID, inviterName, joinDate, RankRecruit)
if !success {
t.Error("Should be able to add member")
}
// Get initial points
member := guild.GetGuildMember(characterID)
if member == nil {
t.Fatal("Member should exist")
}
initialPoints := member.Points
// Add points
pointsToAdd := 100.0
modifiedBy := "TestAdmin"
comment := "Test point award"
success = guild.AddPointsToGuildMember(characterID, pointsToAdd, modifiedBy, comment, false)
if !success {
t.Error("Should be able to add points to member")
}
// Check points were added
member = guild.GetGuildMember(characterID)
expectedPoints := initialPoints + pointsToAdd
if member.Points != expectedPoints {
t.Errorf("Expected points %f, got %f", expectedPoints, member.Points)
}
}
// TestGuildConcurrency tests thread safety of guild operations
func TestGuildConcurrency(t *testing.T) {
var db *database.Database
guild := New(db)
guild.SetID(1)
guild.SetName("Concurrent Test Guild", false)
const numGoroutines = 20
done := make(chan bool, numGoroutines)
// Test concurrent reads
for i := 0; i < numGoroutines; i++ {
go func(id int) {
_ = guild.GetID()
_ = guild.GetName()
_ = guild.GetLevel()
_ = guild.GetMOTD()
_ = guild.GetEXPCurrent()
_ = guild.GetRecruitingShortDesc()
_ = guild.GetRankName(RankMember)
_ = guild.GetPermission(RankMember, PermissionInvite)
done <- true
}(i)
}
// Wait for all read operations
for i := 0; i < numGoroutines; i++ {
<-done
}
// Test concurrent member additions (smaller number to avoid conflicts)
const memberGoroutines = 10
for i := 0; i < memberGoroutines; i++ {
go func(id int) {
inviterName := fmt.Sprintf("Inviter%d", id)
joinDate := time.Now()
characterID := int32(100 + id)
guild.AddNewGuildMember(characterID, inviterName, joinDate, RankRecruit)
done <- true
}(i)
}
// Wait for all member additions
for i := 0; i < memberGoroutines; i++ {
<-done
}
// Verify members were added
members := guild.GetAllMembers()
if len(members) != memberGoroutines {
t.Logf("Expected %d members, got %d (some concurrent additions may have failed, which is acceptable)", memberGoroutines, len(members))
}
}
// TestGuildEventFilters tests guild event filter system
func TestGuildEventFilters(t *testing.T) {
var db *database.Database
guild := New(db)
// Test setting event filters
eventID := int8(EventMemberJoins)
category := int8(EventFilterCategoryBroadcast)
value := int8(1)
success := guild.SetEventFilter(eventID, category, value, false, false)
if !success {
t.Error("Should be able to set event filter")
}
getValue := guild.GetEventFilter(eventID, category)
if getValue != value {
t.Errorf("Expected event filter value %d, got %d", value, getValue)
}
// Test removing event filter
success = guild.SetEventFilter(eventID, category, 0, false, false)
if !success {
t.Error("Should be able to remove event filter")
}
getValue = guild.GetEventFilter(eventID, category)
if getValue != 0 {
t.Errorf("Expected event filter value 0 after removal, got %d", getValue)
}
}
// TestGuildInfo tests the guild info structure
func TestGuildInfo(t *testing.T) {
var db *database.Database
guild := New(db)
guild.SetID(123)
guild.SetName("Test Guild Info", false)
guild.SetLevel(25, false)
guild.SetMOTD("Test MOTD", false)
info := guild.GetGuildInfo()
if info.ID != 123 {
t.Errorf("Expected guild info ID 123, got %d", info.ID)
}
if info.Name != "Test Guild Info" {
t.Errorf("Expected guild info name 'Test Guild Info', got '%s'", info.Name)
}
if info.Level != 25 {
t.Errorf("Expected guild info level 25, got %d", info.Level)
}
if info.MOTD != "Test MOTD" {
t.Errorf("Expected guild info MOTD 'Test MOTD', got '%s'", info.MOTD)
}
}

View File

@ -1,294 +0,0 @@
# Heroic Opportunities System
The Heroic Opportunities (HO) system implements EverQuest II's cooperative combat mechanic where players coordinate ability usage to complete beneficial spell effects.
## Overview
Heroic Opportunities are multi-stage cooperative encounters that require precise timing and coordination. The system consists of two main phases:
1. **Starter Chain Phase**: Players use abilities in sequence to complete a starter chain
2. **Wheel Phase**: Players complete abilities on a randomized wheel within a time limit
## Architecture
### Core Components
#### HeroicOPStarter
Represents starter chains that initiate heroic opportunities:
- **Class Restrictions**: Specific classes can initiate specific starters
- **Ability Sequence**: Up to 6 abilities that must be used in order
- **Completion Marker**: Special marker (0xFFFF) indicates chain completion
#### HeroicOPWheel
Represents the wheel phase with ability completion requirements:
- **Order Types**: Unordered (any sequence) vs Ordered (specific sequence)
- **Shift Capability**: Ability to change to different wheel once per HO
- **Completion Spell**: Spell cast when wheel is successfully completed
- **Chance Weighting**: Probability factor for random wheel selection
#### HeroicOP
Active HO instance with state management:
- **Multi-phase State**: Tracks progression through starter → wheel → completion
- **Timer Management**: Precise timing controls for wheel phase
- **Participant Tracking**: Manages all players involved in the HO
- **Progress Validation**: Ensures abilities match current requirements
### System Flow
```
Player Uses Starter Ability
System Loads Available Starters for Class
Eliminate Non-matching Starters
Starter Complete? → Yes → Select Random Wheel
↓ ↓
No Start Wheel Phase Timer
↓ ↓
Continue Starter Chain Players Complete Abilities
↓ ↓
More Starters? → No → HO Fails All Complete? → Yes → Cast Spell
No → Timer Expired? → Yes → HO Fails
```
## Database Schema
### heroic_ops Table
Stores both starters and wheels with type discrimination:
```sql
CREATE TABLE heroic_ops (
id INTEGER NOT NULL,
ho_type TEXT CHECK(ho_type IN ('Starter', 'Wheel')),
starter_class INTEGER, -- For starters: class restriction
starter_icon INTEGER, -- For starters: initiating icon
starter_link_id INTEGER, -- For wheels: associated starter ID
chain_order INTEGER, -- For wheels: order requirement
shift_icon INTEGER, -- For wheels: shift ability icon
spell_id INTEGER, -- For wheels: completion spell
chance REAL, -- For wheels: selection probability
ability1-6 INTEGER, -- Ability icons
name TEXT,
description TEXT
);
```
### heroic_op_instances Table
Tracks active HO instances:
```sql
CREATE TABLE heroic_op_instances (
id INTEGER PRIMARY KEY,
encounter_id INTEGER,
starter_id INTEGER,
wheel_id INTEGER,
state INTEGER,
countered_1-6 INTEGER, -- Completion status per ability
shift_used INTEGER,
time_remaining INTEGER,
-- ... additional fields
);
```
## Key Features
### Multi-Class Initiation
- Specific classes can initiate specific starter chains
- Universal starters (class 0) available to all classes
- Class validation ensures proper HO eligibility
### Dynamic Wheel Selection
- Random selection from available wheels per starter
- Weighted probability based on chance values
- Prevents predictable HO patterns
### Wheel Shifting
- **One-time ability** to change wheels during wheel phase
- **Timing Restrictions**: Only before progress (unordered) or at start (ordered)
- **Strategic Element**: Allows adaptation to group composition
### Precise Timing
- Configurable wheel phase timers (default 10 seconds)
- Millisecond precision for fair completion windows
- Automatic cleanup of expired HOs
### Order Enforcement
- **Unordered Wheels**: Any ability can be completed in any sequence
- **Ordered Wheels**: Abilities must be completed in specific order
- **Validation**: System prevents invalid ability usage
## Usage Examples
### Starting a Heroic Opportunity
```go
// Initialize HO manager
manager := NewHeroicOPManager(masterList, database, clientManager, encounterManager, playerManager)
manager.Initialize(ctx, config)
// Start HO for encounter
ho, err := manager.StartHeroicOpportunity(ctx, encounterID, initiatorCharacterID)
if err != nil {
return fmt.Errorf("failed to start HO: %w", err)
}
```
### Processing Player Abilities
```go
// Player uses ability during HO
err := manager.ProcessAbility(ctx, ho.ID, characterID, abilityIcon)
if err != nil {
// Ability not allowed or HO in wrong state
return err
}
// Check if HO completed
if ho.IsComplete() {
// Completion spell will be cast automatically
log.Printf("HO completed by character %d", ho.CompletedBy)
}
```
### Timer Management
```go
// Update all active HO timers (called periodically)
manager.UpdateTimers(ctx, deltaMilliseconds)
// Expired HOs are automatically failed and cleaned up
```
## Client Communication
### Packet Types
- **HO Start**: Initial HO initiation notification
- **HO Update**: Wheel phase updates with ability icons
- **HO Progress**: Real-time completion progress
- **HO Timer**: Timer countdown updates
- **HO Complete**: Success/failure notification
- **HO Shift**: Wheel change notification
### Real-time Updates
- Participants receive immediate feedback on ability usage
- Progress updates show completion status
- Timer updates maintain urgency during wheel phase
## Configuration
### System Parameters
```go
config := &HeroicOPConfig{
DefaultWheelTimer: 10000, // 10 seconds in milliseconds
MaxConcurrentHOs: 3, // Per encounter
EnableLogging: true,
EnableStatistics: true,
EnableShifting: true,
RequireClassMatch: true,
}
```
### Performance Tuning
- **Concurrent HOs**: Limit simultaneous HOs per encounter
- **Cleanup Intervals**: Regular removal of expired instances
- **Database Batching**: Efficient event logging
- **Memory Management**: Instance pooling for high-traffic servers
## Integration Points
### Spell System Integration
- Completion spells cast through spell manager
- Spell validation and effect application
- Integration with existing spell mechanics
### Encounter System Integration
- HO availability tied to active encounters
- Participant validation through encounter membership
- Encounter end triggers HO cleanup
### Player System Integration
- Class validation for starter eligibility
- Ability validation for wheel completion
- Player state checking (online, in combat, etc.)
## Error Handling
### Common Error Scenarios
- **Invalid State**: Ability used when HO not in correct phase
- **Timer Expired**: Wheel phase timeout
- **Ability Not Allowed**: Ability doesn't match current requirements
- **Shift Already Used**: Attempting multiple shifts
- **Player Not in Encounter**: Participant validation failure
### Recovery Mechanisms
- Automatic HO failure on unrecoverable errors
- Client notification of error conditions
- Logging for debugging and analysis
- Graceful degradation when components unavailable
## Thread Safety
All core components use proper Go concurrency patterns:
- **RWMutex Protection**: Reader-writer locks for shared data
- **Atomic Operations**: Lock-free operations where possible
- **Context Cancellation**: Proper cleanup on shutdown
- **Channel Communication**: Safe inter-goroutine messaging
## Performance Considerations
### Memory Management
- Object pooling for frequently created instances
- Efficient cleanup of expired HOs
- Bounded history retention
### Database Optimization
- Indexed queries for fast lookups
- Batch operations for event logging
- Connection pooling for concurrent access
### Network Efficiency
- Minimal packet sizes for real-time updates
- Client version-specific optimizations
- Broadcast optimization for group updates
## Testing
### Unit Tests
- Individual component validation
- State machine testing
- Error condition handling
- Concurrent access patterns
### Integration Tests
- Full HO lifecycle scenarios
- Multi-player coordination
- Database persistence validation
- Client communication verification
## Future Enhancements
### Planned Features
- **Lua Scripting**: Custom HO behaviors
- **Advanced Statistics**: Detailed analytics
- **Dynamic Difficulty**: Adaptive timer adjustments
- **Guild Coordination**: Guild-wide HO tracking
### Scalability Improvements
- **Clustering Support**: Multi-server HO coordination
- **Caching Layer**: Redis integration for high-traffic
- **Async Processing**: Background HO processing
- **Load Balancing**: Distribution across game servers
## Conversion Notes
This Go implementation maintains full compatibility with the original C++ EQ2EMu system while modernizing the architecture:
- **Thread Safety**: Proper Go concurrency patterns
- **Error Handling**: Comprehensive error wrapping
- **Context Usage**: Cancellation and timeout support
- **Interface Design**: Modular, testable components
- **Database Integration**: Modern query patterns
All original functionality has been preserved, including complex mechanics like wheel shifting, ordered/unordered completion, and precise timing requirements.

View File

@ -1,777 +0,0 @@
package housing
import (
"context"
"fmt"
"math/rand"
"testing"
"time"
"zombiezen.com/go/sqlite/sqlitex"
)
// setupBenchmarkDB creates a test database with sample data for benchmarking
func setupBenchmarkDB(b *testing.B) (*DatabaseHousingManager, context.Context) {
// Create truly unique database name to avoid cross-benchmark contamination
dbName := fmt.Sprintf("file:bench_%s_%d.db?mode=memory&cache=shared", b.Name(), rand.Int63())
pool, err := sqlitex.NewPool(dbName, sqlitex.PoolOptions{})
if err != nil {
b.Fatalf("Failed to create benchmark database pool: %v", err)
}
dhm := NewDatabaseHousingManager(pool)
ctx := context.Background()
if err := dhm.EnsureHousingTables(ctx); err != nil {
b.Fatalf("Failed to create benchmark tables: %v", err)
}
return dhm, ctx
}
// insertBenchmarkData inserts a large dataset for benchmarking
func insertBenchmarkData(b *testing.B, dhm *DatabaseHousingManager, ctx context.Context, houseZones, playerHouses int) {
// Insert house zones
for i := 1; i <= houseZones; i++ {
zone := &HouseZone{
ID: int32(i),
Name: fmt.Sprintf("House Type %d", i),
ZoneID: int32(100 + i),
CostCoin: int64(50000 + i*10000),
CostStatus: int64(1000 + i*100),
UpkeepCoin: int64(5000 + i*1000),
UpkeepStatus: int64(100 + i*10),
Alignment: int8(i % 3 - 1), // -1, 0, 1
GuildLevel: int8(i % 50),
VaultSlots: int(4 + i%4),
MaxItems: int(100 + i*50),
MaxVisitors: int(10 + i*5),
UpkeepPeriod: 604800, // 1 week
Description: fmt.Sprintf("Benchmark house type %d description", i),
}
if err := dhm.SaveHouseZone(ctx, zone); err != nil {
b.Fatalf("Failed to insert benchmark house zone: %v", err)
}
}
// Insert player houses
for i := 1; i <= playerHouses; i++ {
houseData := PlayerHouseData{
CharacterID: int32(1000 + i),
HouseID: int32((i % houseZones) + 1),
InstanceID: int32(5000 + i),
UpkeepDue: time.Now().Add(time.Duration(i%168) * time.Hour), // Random within a week
EscrowCoins: int64(10000 + i*1000),
EscrowStatus: int64(200 + i*20),
Status: HouseStatusActive,
HouseName: fmt.Sprintf("House %d", i),
VisitPermission: int8(i % 3),
PublicNote: fmt.Sprintf("Welcome to house %d!", i),
PrivateNote: fmt.Sprintf("Private note %d", i),
AllowFriends: i%2 == 0,
AllowGuild: i%3 == 0,
RequireApproval: i%4 == 0,
ShowOnDirectory: i%5 != 0,
AllowDecoration: i%6 != 0,
TaxExempt: i%10 == 0,
}
_, err := dhm.AddPlayerHouse(ctx, houseData)
if err != nil {
b.Fatalf("Failed to insert benchmark player house: %v", err)
}
}
}
// insertHouseRelatedData inserts deposits, history, access, etc. for benchmarking
func insertHouseRelatedData(b *testing.B, dhm *DatabaseHousingManager, ctx context.Context, houseID int64, entries int) {
// Insert deposits
for i := 1; i <= entries; i++ {
deposit := HouseDeposit{
Timestamp: time.Now().Add(-time.Duration(i) * time.Hour),
Amount: int64(1000 + i*100),
LastAmount: int64(2000 + i*100),
Status: int64(50 + i*5),
LastStatus: int64(100 + i*5),
Name: fmt.Sprintf("Player %d", i%10+1),
CharacterID: int32(2000 + i%10),
}
if err := dhm.SaveDeposit(ctx, houseID, deposit); err != nil {
b.Fatalf("Failed to insert benchmark deposit: %v", err)
}
}
// Insert history
for i := 1; i <= entries; i++ {
history := HouseHistory{
Timestamp: time.Now().Add(-time.Duration(i*2) * time.Hour),
Amount: int64(500 + i*50),
Status: int64(25 + i*2),
Reason: fmt.Sprintf("Transaction %d", i),
Name: fmt.Sprintf("Player %d", i%10+1),
CharacterID: int32(2000 + i%10),
PosFlag: int8(i % 2),
Type: int(i % 5),
}
if err := dhm.AddHistory(ctx, houseID, history); err != nil {
b.Fatalf("Failed to insert benchmark history: %v", err)
}
}
// Insert access entries
accessList := make([]HouseAccess, 0, entries/10) // Fewer access entries
for i := 1; i <= entries/10; i++ {
access := HouseAccess{
CharacterID: int32(3000 + i),
PlayerName: fmt.Sprintf("AccessPlayer%d", i),
AccessLevel: int8(i % 3),
Permissions: int32(i % 16), // 0-15
GrantedBy: int32(1001),
GrantedDate: time.Now().Add(-time.Duration(i*24) * time.Hour),
ExpiresDate: time.Now().Add(time.Duration(30-i) * 24 * time.Hour),
Notes: fmt.Sprintf("Access notes for player %d", i),
}
accessList = append(accessList, access)
}
if len(accessList) > 0 {
if err := dhm.SaveHouseAccess(ctx, houseID, accessList); err != nil {
b.Fatalf("Failed to insert benchmark access: %v", err)
}
}
// Insert amenities
for i := 1; i <= entries/5; i++ {
amenity := HouseAmenity{
ID: int32(i),
Type: int(i % 10),
Name: fmt.Sprintf("Amenity %d", i),
Cost: int64(1000 + i*500),
StatusCost: int64(20 + i*10),
PurchaseDate: time.Now().Add(-time.Duration(i*12) * time.Hour),
X: float32(100 + i*10),
Y: float32(200 + i*15),
Z: float32(50 + i*5),
Heading: float32(i % 360),
IsActive: i%2 == 0,
}
if err := dhm.SaveHouseAmenity(ctx, houseID, amenity); err != nil {
b.Fatalf("Failed to insert benchmark amenity: %v", err)
}
}
// Insert items
for i := 1; i <= entries/3; i++ {
item := HouseItem{
ID: int64(i),
ItemID: int32(10000 + i),
CharacterID: int32(1001),
X: float32(150 + i*5),
Y: float32(250 + i*7),
Z: float32(75 + i*3),
Heading: float32(i % 360),
PitchX: float32(i % 10),
PitchY: float32(i % 5),
RollX: float32(i % 15),
RollY: float32(i % 8),
PlacedDate: time.Now().Add(-time.Duration(i*6) * time.Hour),
Quantity: int32(1 + i%5),
Condition: int8(100 - i%100),
House: fmt.Sprintf("room_%d", i%5),
}
if err := dhm.SaveHouseItem(ctx, houseID, item); err != nil {
b.Fatalf("Failed to insert benchmark item: %v", err)
}
}
}
// BenchmarkLoadHouseZones benchmarks loading all house zones
func BenchmarkLoadHouseZones(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 100, 0) // 100 house zones, no player houses
b.ResetTimer()
for i := 0; i < b.N; i++ {
zones, err := dhm.LoadHouseZones(ctx)
if err != nil {
b.Fatalf("LoadHouseZones failed: %v", err)
}
if len(zones) != 100 {
b.Errorf("Expected 100 zones, got %d", len(zones))
}
}
}
// BenchmarkLoadHouseZone benchmarks loading a single house zone
func BenchmarkLoadHouseZone(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 100, 0)
b.ResetTimer()
for i := 0; i < b.N; i++ {
zone, err := dhm.LoadHouseZone(ctx, int32((i%100)+1))
if err != nil {
b.Fatalf("LoadHouseZone failed: %v", err)
}
if zone == nil {
b.Error("LoadHouseZone returned nil zone")
}
}
}
// BenchmarkSaveHouseZone benchmarks saving a house zone
func BenchmarkSaveHouseZone(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
b.ResetTimer()
for i := 0; i < b.N; i++ {
zone := &HouseZone{
ID: int32(1000 + i),
Name: fmt.Sprintf("Benchmark House %d", i),
ZoneID: int32(2000 + i),
CostCoin: int64(50000 + i*1000),
CostStatus: int64(1000 + i*10),
UpkeepCoin: int64(5000 + i*100),
UpkeepStatus: int64(100 + i),
Alignment: int8(i % 3 - 1),
GuildLevel: int8(i % 50),
VaultSlots: int(4 + i%4),
MaxItems: int(100 + i*10),
MaxVisitors: int(10 + i),
UpkeepPeriod: 604800,
Description: fmt.Sprintf("Benchmark description %d", i),
}
if err := dhm.SaveHouseZone(ctx, zone); err != nil {
b.Fatalf("SaveHouseZone failed: %v", err)
}
}
}
// BenchmarkLoadPlayerHouses benchmarks loading player houses
func BenchmarkLoadPlayerHouses(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 10, 1000) // 10 house types, 1000 player houses
b.ResetTimer()
for i := 0; i < b.N; i++ {
characterID := int32(1000 + (i%1000) + 1)
houses, err := dhm.LoadPlayerHouses(ctx, characterID)
if err != nil {
b.Fatalf("LoadPlayerHouses failed: %v", err)
}
if len(houses) != 1 {
b.Errorf("Expected 1 house for character %d, got %d", characterID, len(houses))
}
}
}
// BenchmarkLoadPlayerHouse benchmarks loading a single player house
func BenchmarkLoadPlayerHouse(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 10, 100)
b.ResetTimer()
for i := 0; i < b.N; i++ {
houseID := int64((i % 100) + 1)
house, err := dhm.LoadPlayerHouse(ctx, houseID)
if err != nil {
b.Fatalf("LoadPlayerHouse failed: %v", err)
}
if house == nil {
b.Error("LoadPlayerHouse returned nil house")
}
}
}
// BenchmarkAddPlayerHouse benchmarks adding player houses
func BenchmarkAddPlayerHouse(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 10, 0) // Just house zones
b.ResetTimer()
for i := 0; i < b.N; i++ {
houseData := PlayerHouseData{
CharacterID: int32(5000 + i),
HouseID: int32((i % 10) + 1),
InstanceID: int32(10000 + i),
UpkeepDue: time.Now().Add(24 * time.Hour),
EscrowCoins: int64(25000 + i*100),
EscrowStatus: int64(500 + i*5),
Status: HouseStatusActive,
HouseName: fmt.Sprintf("Benchmark House %d", i),
VisitPermission: int8(i % 3),
PublicNote: fmt.Sprintf("Welcome to benchmark house %d", i),
PrivateNote: fmt.Sprintf("Private note %d", i),
AllowFriends: i%2 == 0,
AllowGuild: i%3 == 0,
RequireApproval: i%4 == 0,
ShowOnDirectory: i%5 != 0,
AllowDecoration: i%6 != 0,
TaxExempt: i%10 == 0,
}
_, err := dhm.AddPlayerHouse(ctx, houseData)
if err != nil {
b.Fatalf("AddPlayerHouse failed: %v", err)
}
}
}
// BenchmarkLoadDeposits benchmarks loading house deposits
func BenchmarkLoadDeposits(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 5, 1)
// Insert deposit data for house ID 1
insertHouseRelatedData(b, dhm, ctx, 1, 500) // 500 deposits
b.ResetTimer()
for i := 0; i < b.N; i++ {
deposits, err := dhm.LoadDeposits(ctx, 1)
if err != nil {
b.Fatalf("LoadDeposits failed: %v", err)
}
if len(deposits) == 0 {
b.Error("LoadDeposits returned no deposits")
}
}
}
// BenchmarkSaveDeposit benchmarks saving deposits
func BenchmarkSaveDeposit(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 5, 1)
b.ResetTimer()
for i := 0; i < b.N; i++ {
deposit := HouseDeposit{
Timestamp: time.Now(),
Amount: int64(1000 + i*10),
LastAmount: int64(2000 + i*10),
Status: int64(50 + i),
LastStatus: int64(100 + i),
Name: fmt.Sprintf("Benchmark Player %d", i),
CharacterID: int32(2000 + i),
}
if err := dhm.SaveDeposit(ctx, 1, deposit); err != nil {
b.Fatalf("SaveDeposit failed: %v", err)
}
}
}
// BenchmarkLoadHistory benchmarks loading house history
func BenchmarkLoadHistory(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 5, 1)
// Insert history data for house ID 1
insertHouseRelatedData(b, dhm, ctx, 1, 500) // 500 history entries
b.ResetTimer()
for i := 0; i < b.N; i++ {
history, err := dhm.LoadHistory(ctx, 1)
if err != nil {
b.Fatalf("LoadHistory failed: %v", err)
}
if len(history) == 0 {
b.Error("LoadHistory returned no history")
}
}
}
// BenchmarkAddHistory benchmarks adding history entries
func BenchmarkAddHistory(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 5, 1)
b.ResetTimer()
for i := 0; i < b.N; i++ {
history := HouseHistory{
Timestamp: time.Now(),
Amount: int64(500 + i*5),
Status: int64(25 + i),
Reason: fmt.Sprintf("Benchmark transaction %d", i),
Name: fmt.Sprintf("Benchmark Player %d", i),
CharacterID: int32(2000 + i),
PosFlag: int8(i % 2),
Type: int(i % 5),
}
if err := dhm.AddHistory(ctx, 1, history); err != nil {
b.Fatalf("AddHistory failed: %v", err)
}
}
}
// BenchmarkLoadHouseAccess benchmarks loading house access
func BenchmarkLoadHouseAccess(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 5, 1)
// Insert access data for house ID 1
insertHouseRelatedData(b, dhm, ctx, 1, 100) // Will create 10 access entries
b.ResetTimer()
for i := 0; i < b.N; i++ {
access, err := dhm.LoadHouseAccess(ctx, 1)
if err != nil {
b.Fatalf("LoadHouseAccess failed: %v", err)
}
if len(access) == 0 {
b.Error("LoadHouseAccess returned no access entries")
}
}
}
// BenchmarkSaveHouseAccess benchmarks saving house access
func BenchmarkSaveHouseAccess(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 5, 1)
b.ResetTimer()
for i := 0; i < b.N; i++ {
accessList := []HouseAccess{
{
CharacterID: int32(4000 + i),
PlayerName: fmt.Sprintf("BenchPlayer%d", i),
AccessLevel: int8(i % 3),
Permissions: int32(i % 16),
GrantedBy: 1001,
GrantedDate: time.Now(),
ExpiresDate: time.Now().Add(30 * 24 * time.Hour),
Notes: fmt.Sprintf("Benchmark access %d", i),
},
}
if err := dhm.SaveHouseAccess(ctx, 1, accessList); err != nil {
b.Fatalf("SaveHouseAccess failed: %v", err)
}
}
}
// BenchmarkLoadHouseAmenities benchmarks loading house amenities
func BenchmarkLoadHouseAmenities(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 5, 1)
// Insert amenity data for house ID 1
insertHouseRelatedData(b, dhm, ctx, 1, 100) // Will create 20 amenities
b.ResetTimer()
for i := 0; i < b.N; i++ {
amenities, err := dhm.LoadHouseAmenities(ctx, 1)
if err != nil {
b.Fatalf("LoadHouseAmenities failed: %v", err)
}
if len(amenities) == 0 {
b.Error("LoadHouseAmenities returned no amenities")
}
}
}
// BenchmarkSaveHouseAmenity benchmarks saving house amenities
func BenchmarkSaveHouseAmenity(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 5, 1)
b.ResetTimer()
for i := 0; i < b.N; i++ {
amenity := HouseAmenity{
ID: int32(5000 + i),
Type: int(i % 10),
Name: fmt.Sprintf("Benchmark Amenity %d", i),
Cost: int64(1000 + i*100),
StatusCost: int64(20 + i*2),
PurchaseDate: time.Now(),
X: float32(100 + i),
Y: float32(200 + i),
Z: float32(50 + i),
Heading: float32(i % 360),
IsActive: i%2 == 0,
}
if err := dhm.SaveHouseAmenity(ctx, 1, amenity); err != nil {
b.Fatalf("SaveHouseAmenity failed: %v", err)
}
}
}
// BenchmarkLoadHouseItems benchmarks loading house items
func BenchmarkLoadHouseItems(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 5, 1)
// Insert item data for house ID 1
insertHouseRelatedData(b, dhm, ctx, 1, 150) // Will create 50 items
b.ResetTimer()
for i := 0; i < b.N; i++ {
items, err := dhm.LoadHouseItems(ctx, 1)
if err != nil {
b.Fatalf("LoadHouseItems failed: %v", err)
}
if len(items) == 0 {
b.Error("LoadHouseItems returned no items")
}
}
}
// BenchmarkSaveHouseItem benchmarks saving house items
func BenchmarkSaveHouseItem(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 5, 1)
b.ResetTimer()
for i := 0; i < b.N; i++ {
item := HouseItem{
ID: int64(6000 + i),
ItemID: int32(20000 + i),
CharacterID: 1001,
X: float32(150 + i),
Y: float32(250 + i),
Z: float32(75 + i),
Heading: float32(i % 360),
PitchX: float32(i % 10),
PitchY: float32(i % 5),
RollX: float32(i % 15),
RollY: float32(i % 8),
PlacedDate: time.Now(),
Quantity: int32(1 + i%5),
Condition: int8(100 - i%100),
House: "benchmark",
}
if err := dhm.SaveHouseItem(ctx, 1, item); err != nil {
b.Fatalf("SaveHouseItem failed: %v", err)
}
}
}
// BenchmarkUpdateHouseUpkeepDue benchmarks updating house upkeep due date
func BenchmarkUpdateHouseUpkeepDue(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 5, 100)
b.ResetTimer()
for i := 0; i < b.N; i++ {
houseID := int64((i % 100) + 1)
newUpkeepDue := time.Now().Add(time.Duration(i) * time.Hour)
if err := dhm.UpdateHouseUpkeepDue(ctx, houseID, newUpkeepDue); err != nil {
b.Fatalf("UpdateHouseUpkeepDue failed: %v", err)
}
}
}
// BenchmarkUpdateHouseEscrow benchmarks updating house escrow
func BenchmarkUpdateHouseEscrow(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 5, 100)
b.ResetTimer()
for i := 0; i < b.N; i++ {
houseID := int64((i % 100) + 1)
coins := int64(50000 + i*1000)
status := int64(1000 + i*10)
if err := dhm.UpdateHouseEscrow(ctx, houseID, coins, status); err != nil {
b.Fatalf("UpdateHouseEscrow failed: %v", err)
}
}
}
// BenchmarkGetHousesForUpkeep benchmarks getting houses for upkeep
func BenchmarkGetHousesForUpkeep(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 10, 1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cutoffTime := time.Now().Add(time.Duration(i%200) * time.Hour)
houses, err := dhm.GetHousesForUpkeep(ctx, cutoffTime)
if err != nil {
b.Fatalf("GetHousesForUpkeep failed: %v", err)
}
_ = houses // Prevent unused variable warning
}
}
// BenchmarkGetHouseStatistics benchmarks getting house statistics
func BenchmarkGetHouseStatistics(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 20, 2000)
// Add some deposits and history for more realistic stats
insertHouseRelatedData(b, dhm, ctx, 1, 100)
insertHouseRelatedData(b, dhm, ctx, 2, 150)
b.ResetTimer()
for i := 0; i < b.N; i++ {
stats, err := dhm.GetHouseStatistics(ctx)
if err != nil {
b.Fatalf("GetHouseStatistics failed: %v", err)
}
if stats.TotalHouses != 2000 {
b.Errorf("Expected 2000 total houses, got %d", stats.TotalHouses)
}
}
}
// BenchmarkGetHouseByInstance benchmarks finding houses by instance ID
func BenchmarkGetHouseByInstance(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 10, 1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
instanceID := int32(5001 + (i % 1000))
house, err := dhm.GetHouseByInstance(ctx, instanceID)
if err != nil {
b.Fatalf("GetHouseByInstance failed: %v", err)
}
if house == nil {
b.Error("GetHouseByInstance returned nil house")
}
}
}
// BenchmarkGetNextHouseID benchmarks getting the next house ID
func BenchmarkGetNextHouseID(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 5, 100)
b.ResetTimer()
for i := 0; i < b.N; i++ {
nextID, err := dhm.GetNextHouseID(ctx)
if err != nil {
b.Fatalf("GetNextHouseID failed: %v", err)
}
if nextID <= 100 {
b.Errorf("Expected next ID > 100, got %d", nextID)
}
}
}
// BenchmarkDeletePlayerHouse benchmarks deleting player houses
func BenchmarkDeletePlayerHouse(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
// We need to create houses to delete in each iteration
b.ResetTimer()
for i := 0; i < b.N; i++ {
b.StopTimer()
// Create a house to delete
houseData := PlayerHouseData{
CharacterID: int32(7000 + i),
HouseID: 1,
InstanceID: int32(8000 + i),
UpkeepDue: time.Now().Add(24 * time.Hour),
EscrowCoins: 25000,
EscrowStatus: 500,
Status: HouseStatusActive,
HouseName: fmt.Sprintf("DeleteMe %d", i),
VisitPermission: 1,
AllowFriends: true,
ShowOnDirectory: true,
}
houseID, err := dhm.AddPlayerHouse(ctx, houseData)
if err != nil {
b.Fatalf("Failed to create house for deletion: %v", err)
}
b.StartTimer()
// Delete the house
if err := dhm.DeletePlayerHouse(ctx, houseID); err != nil {
b.Fatalf("DeletePlayerHouse failed: %v", err)
}
}
}
// BenchmarkConcurrentReads benchmarks concurrent read operations
func BenchmarkConcurrentReads(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 10, 100)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
characterID := int32(1001 + (i % 100))
houses, err := dhm.LoadPlayerHouses(ctx, characterID)
if err != nil {
b.Errorf("LoadPlayerHouses failed: %v", err)
}
if len(houses) != 1 {
b.Errorf("Expected 1 house, got %d", len(houses))
}
i++
}
})
}
// BenchmarkConcurrentWrites benchmarks concurrent write operations
func BenchmarkConcurrentWrites(b *testing.B) {
dhm, ctx := setupBenchmarkDB(b)
insertBenchmarkData(b, dhm, ctx, 10, 0)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
houseData := PlayerHouseData{
CharacterID: int32(10000 + i),
HouseID: int32((i % 10) + 1),
InstanceID: int32(20000 + i),
UpkeepDue: time.Now().Add(24 * time.Hour),
EscrowCoins: 25000,
EscrowStatus: 500,
Status: HouseStatusActive,
HouseName: fmt.Sprintf("Concurrent House %d", i),
VisitPermission: 1,
AllowFriends: true,
ShowOnDirectory: true,
}
_, err := dhm.AddPlayerHouse(ctx, houseData)
if err != nil {
b.Errorf("AddPlayerHouse failed: %v", err)
}
i++
}
})
}

View File

@ -1,816 +0,0 @@
package housing
import (
"context"
"fmt"
"testing"
"time"
"zombiezen.com/go/sqlite/sqlitex"
)
// createTestPool creates an in-memory SQLite database pool for testing
func createTestPool(t *testing.T) *sqlitex.Pool {
dbName := fmt.Sprintf("file:test_%s.db?mode=memory&cache=shared", t.Name())
pool, err := sqlitex.NewPool(dbName, sqlitex.PoolOptions{})
if err != nil {
t.Fatalf("Failed to create test database pool: %v", err)
}
return pool
}
// setupTestDB creates test tables and returns a DatabaseHousingManager
func setupTestDB(t *testing.T) *DatabaseHousingManager {
pool := createTestPool(t)
dhm := NewDatabaseHousingManager(pool)
ctx := context.Background()
if err := dhm.EnsureHousingTables(ctx); err != nil {
t.Fatalf("Failed to create test tables: %v", err)
}
return dhm
}
// insertTestData inserts sample data for testing
func insertTestData(t *testing.T, dhm *DatabaseHousingManager) {
ctx := context.Background()
// Insert test house zones
testZones := []*HouseZone{
{
ID: 1,
Name: "Small Studio",
ZoneID: 100,
CostCoin: 50000,
CostStatus: 1000,
UpkeepCoin: 5000,
UpkeepStatus: 100,
Alignment: 0, // Neutral
GuildLevel: 0,
VaultSlots: 4,
MaxItems: 100,
MaxVisitors: 10,
UpkeepPeriod: 604800, // 1 week
Description: "A cozy small studio apartment",
},
{
ID: 2,
Name: "Large House",
ZoneID: 101,
CostCoin: 500000,
CostStatus: 10000,
UpkeepCoin: 50000,
UpkeepStatus: 1000,
Alignment: 1, // Good
GuildLevel: 20,
VaultSlots: 8,
MaxItems: 500,
MaxVisitors: 50,
UpkeepPeriod: 604800,
Description: "A spacious large house",
},
}
for _, zone := range testZones {
if err := dhm.SaveHouseZone(ctx, zone); err != nil {
t.Fatalf("Failed to insert test house zone: %v", err)
}
}
// Insert test player houses
testPlayerHouses := []PlayerHouseData{
{
CharacterID: 1001,
HouseID: 1,
InstanceID: 5001,
UpkeepDue: time.Now().Add(24 * time.Hour),
EscrowCoins: 25000,
EscrowStatus: 500,
Status: HouseStatusActive,
HouseName: "Alice's Studio",
VisitPermission: 1,
PublicNote: "Welcome to my home!",
PrivateNote: "Remember to water plants",
AllowFriends: true,
AllowGuild: false,
RequireApproval: false,
ShowOnDirectory: true,
AllowDecoration: true,
TaxExempt: false,
},
{
CharacterID: 1002,
HouseID: 2,
InstanceID: 5002,
UpkeepDue: time.Now().Add(48 * time.Hour),
EscrowCoins: 100000,
EscrowStatus: 2000,
Status: HouseStatusActive,
HouseName: "Bob's Manor",
VisitPermission: 2,
PublicNote: "Guild meetings welcome",
PrivateNote: "Check security settings",
AllowFriends: true,
AllowGuild: true,
RequireApproval: true,
ShowOnDirectory: true,
AllowDecoration: false,
TaxExempt: true,
},
}
for _, house := range testPlayerHouses {
_, err := dhm.AddPlayerHouse(ctx, house)
if err != nil {
t.Fatalf("Failed to insert test player house: %v", err)
}
}
}
func TestNewDatabaseHousingManager(t *testing.T) {
pool := createTestPool(t)
dhm := NewDatabaseHousingManager(pool)
if dhm == nil {
t.Fatal("NewDatabaseHousingManager returned nil")
}
if dhm.pool != pool {
t.Error("Database pool not set correctly")
}
}
func TestEnsureHousingTables(t *testing.T) {
dhm := setupTestDB(t)
ctx := context.Background()
// Test that tables were created (this should not error on second call)
if err := dhm.EnsureHousingTables(ctx); err != nil {
t.Errorf("EnsureHousingTables failed on second call: %v", err)
}
}
func TestHouseZoneOperations(t *testing.T) {
dhm := setupTestDB(t)
ctx := context.Background()
// Test SaveHouseZone and LoadHouseZone
testZone := &HouseZone{
ID: 100,
Name: "Test House",
ZoneID: 200,
CostCoin: 100000,
CostStatus: 2000,
UpkeepCoin: 10000,
UpkeepStatus: 200,
Alignment: -1, // Evil
GuildLevel: 10,
VaultSlots: 6,
MaxItems: 250,
MaxVisitors: 25,
UpkeepPeriod: 1209600, // 2 weeks
Description: "A test house for unit testing",
}
// Save house zone
if err := dhm.SaveHouseZone(ctx, testZone); err != nil {
t.Fatalf("SaveHouseZone failed: %v", err)
}
// Load house zone
loadedZone, err := dhm.LoadHouseZone(ctx, testZone.ID)
if err != nil {
t.Fatalf("LoadHouseZone failed: %v", err)
}
// Verify loaded data
if loadedZone.ID != testZone.ID {
t.Errorf("Expected ID %d, got %d", testZone.ID, loadedZone.ID)
}
if loadedZone.Name != testZone.Name {
t.Errorf("Expected Name %s, got %s", testZone.Name, loadedZone.Name)
}
if loadedZone.Description != testZone.Description {
t.Errorf("Expected Description %s, got %s", testZone.Description, loadedZone.Description)
}
// Test LoadHouseZones
zones, err := dhm.LoadHouseZones(ctx)
if err != nil {
t.Fatalf("LoadHouseZones failed: %v", err)
}
if len(zones) != 1 {
t.Errorf("Expected 1 zone, got %d", len(zones))
}
// Test DeleteHouseZone
if err := dhm.DeleteHouseZone(ctx, testZone.ID); err != nil {
t.Fatalf("DeleteHouseZone failed: %v", err)
}
// Verify deletion
_, err = dhm.LoadHouseZone(ctx, testZone.ID)
if err == nil {
t.Error("Expected error when loading deleted house zone, got nil")
}
}
func TestPlayerHouseOperations(t *testing.T) {
dhm := setupTestDB(t)
insertTestData(t, dhm)
ctx := context.Background()
// Test LoadPlayerHouses
houses, err := dhm.LoadPlayerHouses(ctx, 1001)
if err != nil {
t.Fatalf("LoadPlayerHouses failed: %v", err)
}
if len(houses) != 1 {
t.Errorf("Expected 1 house for character 1001, got %d", len(houses))
}
if houses[0].HouseName != "Alice's Studio" {
t.Errorf("Expected house name 'Alice's Studio', got %s", houses[0].HouseName)
}
// Test LoadPlayerHouse by unique ID
house, err := dhm.LoadPlayerHouse(ctx, houses[0].UniqueID)
if err != nil {
t.Fatalf("LoadPlayerHouse failed: %v", err)
}
if house.CharacterID != 1001 {
t.Errorf("Expected character ID 1001, got %d", house.CharacterID)
}
// Test GetHouseByInstance
houseByInstance, err := dhm.GetHouseByInstance(ctx, 5001)
if err != nil {
t.Fatalf("GetHouseByInstance failed: %v", err)
}
if houseByInstance.CharacterID != 1001 {
t.Errorf("Expected character ID 1001, got %d", houseByInstance.CharacterID)
}
// Test GetNextHouseID
nextID, err := dhm.GetNextHouseID(ctx)
if err != nil {
t.Fatalf("GetNextHouseID failed: %v", err)
}
if nextID <= houses[0].UniqueID {
t.Errorf("Expected next ID > %d, got %d", houses[0].UniqueID, nextID)
}
}
func TestPlayerHouseUpdates(t *testing.T) {
dhm := setupTestDB(t)
insertTestData(t, dhm)
ctx := context.Background()
// Get a test house
houses, err := dhm.LoadPlayerHouses(ctx, 1001)
if err != nil || len(houses) == 0 {
t.Fatalf("Failed to load test house: %v", err)
}
houseID := houses[0].UniqueID
// Test UpdateHouseUpkeepDue
newUpkeepDue := time.Now().Add(72 * time.Hour)
if err := dhm.UpdateHouseUpkeepDue(ctx, houseID, newUpkeepDue); err != nil {
t.Fatalf("UpdateHouseUpkeepDue failed: %v", err)
}
// Verify update
updatedHouse, err := dhm.LoadPlayerHouse(ctx, houseID)
if err != nil {
t.Fatalf("Failed to load updated house: %v", err)
}
if updatedHouse.UpkeepDue.Unix() != newUpkeepDue.Unix() {
t.Errorf("Expected upkeep due %v, got %v", newUpkeepDue, updatedHouse.UpkeepDue)
}
// Test UpdateHouseEscrow
if err := dhm.UpdateHouseEscrow(ctx, houseID, 50000, 1000); err != nil {
t.Fatalf("UpdateHouseEscrow failed: %v", err)
}
// Verify escrow update
updatedHouse, err = dhm.LoadPlayerHouse(ctx, houseID)
if err != nil {
t.Fatalf("Failed to load updated house: %v", err)
}
if updatedHouse.EscrowCoins != 50000 {
t.Errorf("Expected escrow coins 50000, got %d", updatedHouse.EscrowCoins)
}
if updatedHouse.EscrowStatus != 1000 {
t.Errorf("Expected escrow status 1000, got %d", updatedHouse.EscrowStatus)
}
}
func TestHouseDeposits(t *testing.T) {
dhm := setupTestDB(t)
insertTestData(t, dhm)
ctx := context.Background()
// Get a test house
houses, err := dhm.LoadPlayerHouses(ctx, 1001)
if err != nil || len(houses) == 0 {
t.Fatalf("Failed to load test house: %v", err)
}
houseID := houses[0].UniqueID
// Test SaveDeposit
testDeposit := HouseDeposit{
Timestamp: time.Now(),
Amount: 10000,
LastAmount: 15000,
Status: 200,
LastStatus: 300,
Name: "Alice",
CharacterID: 1001,
}
if err := dhm.SaveDeposit(ctx, houseID, testDeposit); err != nil {
t.Fatalf("SaveDeposit failed: %v", err)
}
// Test LoadDeposits
deposits, err := dhm.LoadDeposits(ctx, houseID)
if err != nil {
t.Fatalf("LoadDeposits failed: %v", err)
}
if len(deposits) != 1 {
t.Errorf("Expected 1 deposit, got %d", len(deposits))
}
if deposits[0].Amount != testDeposit.Amount {
t.Errorf("Expected deposit amount %d, got %d", testDeposit.Amount, deposits[0].Amount)
}
if deposits[0].Name != testDeposit.Name {
t.Errorf("Expected deposit name %s, got %s", testDeposit.Name, deposits[0].Name)
}
}
func TestHouseHistory(t *testing.T) {
dhm := setupTestDB(t)
insertTestData(t, dhm)
ctx := context.Background()
// Get a test house
houses, err := dhm.LoadPlayerHouses(ctx, 1001)
if err != nil || len(houses) == 0 {
t.Fatalf("Failed to load test house: %v", err)
}
houseID := houses[0].UniqueID
// Test AddHistory
testHistory := HouseHistory{
Timestamp: time.Now(),
Amount: 5000,
Status: 100,
Reason: "Weekly upkeep",
Name: "System",
CharacterID: 0, // System transaction
PosFlag: 0, // Withdrawal
Type: 1, // Upkeep
}
if err := dhm.AddHistory(ctx, houseID, testHistory); err != nil {
t.Fatalf("AddHistory failed: %v", err)
}
// Test LoadHistory
history, err := dhm.LoadHistory(ctx, houseID)
if err != nil {
t.Fatalf("LoadHistory failed: %v", err)
}
if len(history) != 1 {
t.Errorf("Expected 1 history entry, got %d", len(history))
}
if history[0].Reason != testHistory.Reason {
t.Errorf("Expected history reason %s, got %s", testHistory.Reason, history[0].Reason)
}
if history[0].Amount != testHistory.Amount {
t.Errorf("Expected history amount %d, got %d", testHistory.Amount, history[0].Amount)
}
}
func TestHouseAccess(t *testing.T) {
dhm := setupTestDB(t)
insertTestData(t, dhm)
ctx := context.Background()
// Get a test house
houses, err := dhm.LoadPlayerHouses(ctx, 1001)
if err != nil || len(houses) == 0 {
t.Fatalf("Failed to load test house: %v", err)
}
houseID := houses[0].UniqueID
// Test SaveHouseAccess
testAccess := []HouseAccess{
{
CharacterID: 2001,
PlayerName: "Bob",
AccessLevel: 1,
Permissions: 15, // Full permissions
GrantedBy: 1001,
GrantedDate: time.Now(),
ExpiresDate: time.Now().Add(30 * 24 * time.Hour),
Notes: "Trusted friend",
},
{
CharacterID: 2002,
PlayerName: "Charlie",
AccessLevel: 2,
Permissions: 7, // Limited permissions
GrantedBy: 1001,
GrantedDate: time.Now(),
ExpiresDate: time.Now().Add(7 * 24 * time.Hour),
Notes: "Temporary access",
},
}
if err := dhm.SaveHouseAccess(ctx, houseID, testAccess); err != nil {
t.Fatalf("SaveHouseAccess failed: %v", err)
}
// Test LoadHouseAccess
accessList, err := dhm.LoadHouseAccess(ctx, houseID)
if err != nil {
t.Fatalf("LoadHouseAccess failed: %v", err)
}
if len(accessList) != 2 {
t.Errorf("Expected 2 access entries, got %d", len(accessList))
}
// Test DeleteHouseAccess
if err := dhm.DeleteHouseAccess(ctx, houseID, 2002); err != nil {
t.Fatalf("DeleteHouseAccess failed: %v", err)
}
// Verify deletion
accessList, err = dhm.LoadHouseAccess(ctx, houseID)
if err != nil {
t.Fatalf("LoadHouseAccess after deletion failed: %v", err)
}
if len(accessList) != 1 {
t.Errorf("Expected 1 access entry after deletion, got %d", len(accessList))
}
if accessList[0].CharacterID != 2001 {
t.Errorf("Expected remaining access for character 2001, got %d", accessList[0].CharacterID)
}
}
func TestHouseAmenities(t *testing.T) {
dhm := setupTestDB(t)
insertTestData(t, dhm)
ctx := context.Background()
// Get a test house
houses, err := dhm.LoadPlayerHouses(ctx, 1001)
if err != nil || len(houses) == 0 {
t.Fatalf("Failed to load test house: %v", err)
}
houseID := houses[0].UniqueID
// Test SaveHouseAmenity
testAmenity := HouseAmenity{
ID: 1,
Type: 1, // Furniture
Name: "Comfortable Chair",
Cost: 1000,
StatusCost: 20,
PurchaseDate: time.Now(),
X: 100.5,
Y: 200.0,
Z: 50.25,
Heading: 180.0,
IsActive: true,
}
if err := dhm.SaveHouseAmenity(ctx, houseID, testAmenity); err != nil {
t.Fatalf("SaveHouseAmenity failed: %v", err)
}
// Test LoadHouseAmenities
amenities, err := dhm.LoadHouseAmenities(ctx, houseID)
if err != nil {
t.Fatalf("LoadHouseAmenities failed: %v", err)
}
if len(amenities) != 1 {
t.Errorf("Expected 1 amenity, got %d", len(amenities))
}
if amenities[0].Name != testAmenity.Name {
t.Errorf("Expected amenity name %s, got %s", testAmenity.Name, amenities[0].Name)
}
if amenities[0].X != testAmenity.X {
t.Errorf("Expected X position %f, got %f", testAmenity.X, amenities[0].X)
}
// Test DeleteHouseAmenity
if err := dhm.DeleteHouseAmenity(ctx, houseID, testAmenity.ID); err != nil {
t.Fatalf("DeleteHouseAmenity failed: %v", err)
}
// Verify deletion
amenities, err = dhm.LoadHouseAmenities(ctx, houseID)
if err != nil {
t.Fatalf("LoadHouseAmenities after deletion failed: %v", err)
}
if len(amenities) != 0 {
t.Errorf("Expected 0 amenities after deletion, got %d", len(amenities))
}
}
func TestHouseItems(t *testing.T) {
dhm := setupTestDB(t)
insertTestData(t, dhm)
ctx := context.Background()
// Get a test house
houses, err := dhm.LoadPlayerHouses(ctx, 1001)
if err != nil || len(houses) == 0 {
t.Fatalf("Failed to load test house: %v", err)
}
houseID := houses[0].UniqueID
// Test SaveHouseItem
testItem := HouseItem{
ID: 1,
ItemID: 12345,
CharacterID: 1001,
X: 150.0,
Y: 250.0,
Z: 75.5,
Heading: 90.0,
PitchX: 5.0,
PitchY: 0.0,
RollX: 0.0,
RollY: 2.5,
PlacedDate: time.Now(),
Quantity: 1,
Condition: 100,
House: "main",
}
if err := dhm.SaveHouseItem(ctx, houseID, testItem); err != nil {
t.Fatalf("SaveHouseItem failed: %v", err)
}
// Test LoadHouseItems
items, err := dhm.LoadHouseItems(ctx, houseID)
if err != nil {
t.Fatalf("LoadHouseItems failed: %v", err)
}
if len(items) != 1 {
t.Errorf("Expected 1 item, got %d", len(items))
}
if items[0].ItemID != testItem.ItemID {
t.Errorf("Expected item ID %d, got %d", testItem.ItemID, items[0].ItemID)
}
if items[0].House != testItem.House {
t.Errorf("Expected house %s, got %s", testItem.House, items[0].House)
}
// Test DeleteHouseItem
if err := dhm.DeleteHouseItem(ctx, houseID, testItem.ID); err != nil {
t.Fatalf("DeleteHouseItem failed: %v", err)
}
// Verify deletion
items, err = dhm.LoadHouseItems(ctx, houseID)
if err != nil {
t.Fatalf("LoadHouseItems after deletion failed: %v", err)
}
if len(items) != 0 {
t.Errorf("Expected 0 items after deletion, got %d", len(items))
}
}
func TestGetHousesForUpkeep(t *testing.T) {
dhm := setupTestDB(t)
insertTestData(t, dhm)
ctx := context.Background()
// Test with cutoff time in the future (should find houses)
cutoffTime := time.Now().Add(72 * time.Hour)
houses, err := dhm.GetHousesForUpkeep(ctx, cutoffTime)
if err != nil {
t.Fatalf("GetHousesForUpkeep failed: %v", err)
}
if len(houses) != 2 {
t.Errorf("Expected 2 houses for upkeep, got %d", len(houses))
}
// Test with cutoff time in the past (should find no houses)
cutoffTime = time.Now().Add(-24 * time.Hour)
houses, err = dhm.GetHousesForUpkeep(ctx, cutoffTime)
if err != nil {
t.Fatalf("GetHousesForUpkeep with past cutoff failed: %v", err)
}
if len(houses) != 0 {
t.Errorf("Expected 0 houses for past upkeep, got %d", len(houses))
}
}
func TestGetHouseStatistics(t *testing.T) {
dhm := setupTestDB(t)
insertTestData(t, dhm)
ctx := context.Background()
// Add some test data for statistics
houses, err := dhm.LoadPlayerHouses(ctx, 1001)
if err != nil || len(houses) == 0 {
t.Fatalf("Failed to load test house: %v", err)
}
houseID := houses[0].UniqueID
// Add some deposits and history for stats
testDeposit := HouseDeposit{
Timestamp: time.Now(),
Amount: 5000,
LastAmount: 10000,
Status: 100,
LastStatus: 200,
Name: "Alice",
CharacterID: 1001,
}
if err := dhm.SaveDeposit(ctx, houseID, testDeposit); err != nil {
t.Fatalf("Failed to save test deposit: %v", err)
}
testHistory := HouseHistory{
Timestamp: time.Now(),
Amount: 2000,
Status: 50,
Reason: "Withdrawal",
Name: "Alice",
CharacterID: 1001,
PosFlag: 0, // Withdrawal
Type: 2,
}
if err := dhm.AddHistory(ctx, houseID, testHistory); err != nil {
t.Fatalf("Failed to add test history: %v", err)
}
// Test GetHouseStatistics
stats, err := dhm.GetHouseStatistics(ctx)
if err != nil {
t.Fatalf("GetHouseStatistics failed: %v", err)
}
if stats.TotalHouses != 2 {
t.Errorf("Expected 2 total houses, got %d", stats.TotalHouses)
}
if stats.ActiveHouses != 2 {
t.Errorf("Expected 2 active houses, got %d", stats.ActiveHouses)
}
if stats.TotalDeposits != 1 {
t.Errorf("Expected 1 total deposits, got %d", stats.TotalDeposits)
}
if stats.TotalWithdrawals != 1 {
t.Errorf("Expected 1 total withdrawals, got %d", stats.TotalWithdrawals)
}
}
func TestDeletePlayerHouse(t *testing.T) {
dhm := setupTestDB(t)
insertTestData(t, dhm)
ctx := context.Background()
// Get a test house
houses, err := dhm.LoadPlayerHouses(ctx, 1001)
if err != nil || len(houses) == 0 {
t.Fatalf("Failed to load test house: %v", err)
}
houseID := houses[0].UniqueID
// Add some related data that should be cascade deleted
testDeposit := HouseDeposit{
Timestamp: time.Now(),
Amount: 5000,
LastAmount: 10000,
Status: 100,
LastStatus: 200,
Name: "Alice",
CharacterID: 1001,
}
if err := dhm.SaveDeposit(ctx, houseID, testDeposit); err != nil {
t.Fatalf("Failed to save test deposit: %v", err)
}
// Test DeletePlayerHouse
if err := dhm.DeletePlayerHouse(ctx, houseID); err != nil {
t.Fatalf("DeletePlayerHouse failed: %v", err)
}
// Verify deletion
_, err = dhm.LoadPlayerHouse(ctx, houseID)
if err == nil {
t.Error("Expected error when loading deleted player house, got nil")
}
// Verify related data was also deleted
deposits, err := dhm.LoadDeposits(ctx, houseID)
if err != nil {
t.Fatalf("LoadDeposits after house deletion failed: %v", err)
}
if len(deposits) != 0 {
t.Errorf("Expected 0 deposits after house deletion, got %d", len(deposits))
}
// Verify other houses are still there
remainingHouses, err := dhm.LoadPlayerHouses(ctx, 1002)
if err != nil {
t.Fatalf("Failed to load remaining houses: %v", err)
}
if len(remainingHouses) != 1 {
t.Errorf("Expected 1 remaining house, got %d", len(remainingHouses))
}
}
func TestErrorCases(t *testing.T) {
dhm := setupTestDB(t)
ctx := context.Background()
// Test loading non-existent house zone
_, err := dhm.LoadHouseZone(ctx, 999)
if err == nil {
t.Error("Expected error when loading non-existent house zone, got nil")
}
// Test loading non-existent player house
_, err = dhm.LoadPlayerHouse(ctx, 999)
if err == nil {
t.Error("Expected error when loading non-existent player house, got nil")
}
// Test loading house by non-existent instance
_, err = dhm.GetHouseByInstance(ctx, 999)
if err == nil {
t.Error("Expected error when loading house by non-existent instance, got nil")
}
// Test operations on non-existent house
nonExistentHouseID := int64(999)
deposits, err := dhm.LoadDeposits(ctx, nonExistentHouseID)
if err != nil {
t.Errorf("LoadDeposits should not error on non-existent house: %v", err)
}
if len(deposits) != 0 {
t.Errorf("Expected 0 deposits for non-existent house, got %d", len(deposits))
}
history, err := dhm.LoadHistory(ctx, nonExistentHouseID)
if err != nil {
t.Errorf("LoadHistory should not error on non-existent house: %v", err)
}
if len(history) != 0 {
t.Errorf("Expected 0 history entries for non-existent house, got %d", len(history))
}
}
// Helper function to compare times with tolerance for database precision
func timesEqual(t1, t2 time.Time, tolerance time.Duration) bool {
diff := t1.Sub(t2)
if diff < 0 {
diff = -diff
}
return diff <= tolerance
}

View File

@ -1,259 +0,0 @@
package items
import (
"context"
"fmt"
"math/rand"
"testing"
"zombiezen.com/go/sqlite/sqlitex"
)
// setupTestDB creates a test database with minimal schema
func setupTestDB(t *testing.T) *sqlitex.Pool {
// Create unique database name to avoid test contamination
dbName := fmt.Sprintf("file:test_%s_%d.db?mode=memory&cache=shared", t.Name(), rand.Int63())
pool, err := sqlitex.NewPool(dbName, sqlitex.PoolOptions{
PoolSize: 10,
})
if err != nil {
t.Fatalf("Failed to create test database pool: %v", err)
}
// Create complete test schema matching the real database structure
schema := `
CREATE TABLE items (
id INTEGER PRIMARY KEY,
soe_id INTEGER DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
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 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
);
`
// Execute schema on connection
ctx := context.Background()
conn, err := pool.Take(ctx)
if err != nil {
t.Fatalf("Failed to get connection: %v", err)
}
defer pool.Put(conn)
if err := sqlitex.ExecuteScript(conn, schema, nil); err != nil {
t.Fatalf("Failed to create test schema: %v", err)
}
return pool
}
func TestNewItemDatabase(t *testing.T) {
pool := setupTestDB(t)
defer pool.Close()
idb := NewItemDatabase(pool)
if idb == nil {
t.Fatal("Expected non-nil ItemDatabase")
}
if idb.pool == nil {
t.Fatal("Expected non-nil database pool")
}
}
func TestItemDatabaseBasicOperation(t *testing.T) {
pool := setupTestDB(t)
defer pool.Close()
idb := NewItemDatabase(pool)
masterList := NewMasterItemList()
// Test that LoadItems doesn't crash (even with empty database)
err := idb.LoadItems(masterList)
if err != nil {
t.Fatalf("LoadItems should not fail with empty database: %v", err)
}
if masterList.GetItemCount() != 0 {
t.Errorf("Expected empty master list, got %d items", masterList.GetItemCount())
}
}
func TestItemDatabaseWithData(t *testing.T) {
pool := setupTestDB(t)
defer pool.Close()
// Insert test data
ctx := context.Background()
conn, err := pool.Take(ctx)
if err != nil {
t.Fatalf("Failed to get connection: %v", err)
}
defer pool.Put(conn)
// Insert a test item
err = sqlitex.Execute(conn, `INSERT INTO items (id, name, generic_info_item_type) VALUES (?, ?, ?)`, &sqlitex.ExecOptions{
Args: []any{1, "Test Sword", ItemTypeWeapon},
})
if err != nil {
t.Fatalf("Failed to insert test item: %v", err)
}
// Insert weapon details
err = sqlitex.Execute(conn, `INSERT INTO item_details_weapon (item_id, damage_low1, damage_high1) VALUES (?, ?, ?)`, &sqlitex.ExecOptions{
Args: []any{1, 10, 15},
})
if err != nil {
t.Fatalf("Failed to insert weapon details: %v", err)
}
// Load items
idb := NewItemDatabase(pool)
masterList := NewMasterItemList()
err = idb.LoadItems(masterList)
if err != nil {
t.Fatalf("Failed to load items: %v", err)
}
if masterList.GetItemCount() != 1 {
t.Errorf("Expected 1 item, got %d items", masterList.GetItemCount())
}
// Verify the item was loaded correctly
item := masterList.GetItem(1)
if item == nil {
t.Fatal("Expected to find item with ID 1")
}
if item.Name != "Test Sword" {
t.Errorf("Expected item name 'Test Sword', got '%s'", item.Name)
}
if item.WeaponInfo == nil {
t.Error("Expected weapon info to be loaded")
} else {
if item.WeaponInfo.DamageLow1 != 10 {
t.Errorf("Expected damage low 10, got %d", item.WeaponInfo.DamageLow1)
}
if item.WeaponInfo.DamageHigh1 != 15 {
t.Errorf("Expected damage high 15, got %d", item.WeaponInfo.DamageHigh1)
}
}
}

View File

@ -1,849 +0,0 @@
package items
import (
"fmt"
"testing"
)
func TestNewItem(t *testing.T) {
item := NewItem()
if item == nil {
t.Fatal("NewItem returned nil")
}
if item.Details.UniqueID <= 0 {
t.Error("New item should have a valid unique ID")
}
if item.Details.Count != 1 {
t.Errorf("Expected count 1, got %d", item.Details.Count)
}
if item.GenericInfo.Condition != DefaultItemCondition {
t.Errorf("Expected condition %d, got %d", DefaultItemCondition, item.GenericInfo.Condition)
}
}
func TestNewItemFromTemplate(t *testing.T) {
// Create template
template := NewItem()
template.Name = "Test Sword"
template.Description = "A test weapon"
template.Details.ItemID = 12345
template.Details.Icon = 100
template.GenericInfo.ItemType = ItemTypeWeapon
template.WeaponInfo = &WeaponInfo{
WieldType: ItemWieldTypeSingle,
DamageLow1: 10,
DamageHigh1: 20,
Delay: 30,
Rating: 1.5,
}
// Create from template
item := NewItemFromTemplate(template)
if item == nil {
t.Fatal("NewItemFromTemplate returned nil")
}
if item.Name != template.Name {
t.Errorf("Expected name %s, got %s", template.Name, item.Name)
}
if item.Details.ItemID != template.Details.ItemID {
t.Errorf("Expected item ID %d, got %d", template.Details.ItemID, item.Details.ItemID)
}
if item.Details.UniqueID == template.Details.UniqueID {
t.Error("New item should have different unique ID from template")
}
if item.WeaponInfo == nil {
t.Fatal("Weapon info should be copied")
}
if item.WeaponInfo.DamageLow1 != template.WeaponInfo.DamageLow1 {
t.Errorf("Expected damage %d, got %d", template.WeaponInfo.DamageLow1, item.WeaponInfo.DamageLow1)
}
}
func TestItemCopy(t *testing.T) {
original := NewItem()
original.Name = "Original Item"
original.Details.ItemID = 999
original.AddStat(&ItemStat{
StatName: "Strength",
StatType: ItemStatStr,
Value: 10,
})
copy := original.Copy()
if copy == nil {
t.Fatal("Copy returned nil")
}
if copy.Name != original.Name {
t.Errorf("Expected name %s, got %s", original.Name, copy.Name)
}
if copy.Details.UniqueID == original.Details.UniqueID {
t.Error("Copy should have different unique ID")
}
if len(copy.ItemStats) != len(original.ItemStats) {
t.Errorf("Expected %d stats, got %d", len(original.ItemStats), len(copy.ItemStats))
}
// Test nil copy
var nilItem *Item
nilCopy := nilItem.Copy()
if nilCopy != nil {
t.Error("Copy of nil should return nil")
}
}
func TestItemValidation(t *testing.T) {
// Valid item
item := NewItem()
item.Name = "Valid Item"
item.Details.ItemID = 100
result := item.Validate()
if !result.Valid {
t.Errorf("Valid item should pass validation: %v", result.Errors)
}
// Invalid item - no name
invalidItem := NewItem()
invalidItem.Details.ItemID = 100
result = invalidItem.Validate()
if result.Valid {
t.Error("Item without name should fail validation")
}
// Invalid item - negative count
invalidItem2 := NewItem()
invalidItem2.Name = "Invalid Item"
invalidItem2.Details.ItemID = 100
invalidItem2.Details.Count = -1
result = invalidItem2.Validate()
if result.Valid {
t.Error("Item with negative count should fail validation")
}
}
func TestItemStats(t *testing.T) {
item := NewItem()
// Add a stat
stat := &ItemStat{
StatName: "Strength",
StatType: ItemStatStr,
Value: 15,
Level: 1,
}
item.AddStat(stat)
if len(item.ItemStats) != 1 {
t.Errorf("Expected 1 stat, got %d", len(item.ItemStats))
}
// Check if item has stat
if !item.HasStat(0, "Strength") {
t.Error("Item should have Strength stat")
}
if !item.HasStat(uint32(ItemStatStr), "") {
t.Error("Item should have STR stat by ID")
}
if item.HasStat(0, "Nonexistent") {
t.Error("Item should not have nonexistent stat")
}
// Add stat by values
item.AddStatByValues(ItemStatAgi, 0, 10, 1, "Agility")
if len(item.ItemStats) != 2 {
t.Errorf("Expected 2 stats, got %d", len(item.ItemStats))
}
}
func TestItemFlags(t *testing.T) {
item := NewItem()
// Set flags
item.GenericInfo.ItemFlags = Attuned | NoTrade
item.GenericInfo.ItemFlags2 = Heirloom | Ornate
// Test flag checking
if !item.CheckFlag(Attuned) {
t.Error("Item should be attuned")
}
if !item.CheckFlag(NoTrade) {
t.Error("Item should be no-trade")
}
if item.CheckFlag(Lore) {
t.Error("Item should not be lore")
}
if !item.CheckFlag2(Heirloom) {
t.Error("Item should be heirloom")
}
if !item.CheckFlag2(Ornate) {
t.Error("Item should be ornate")
}
if item.CheckFlag2(Refined) {
t.Error("Item should not be refined")
}
}
func TestItemLocking(t *testing.T) {
item := NewItem()
// Item should not be locked initially
if item.IsItemLocked() {
t.Error("New item should not be locked")
}
// Lock for crafting
if !item.TryLockItem(LockReasonCrafting) {
t.Error("Should be able to lock item for crafting")
}
if !item.IsItemLocked() {
t.Error("Item should be locked")
}
if !item.IsItemLockedFor(LockReasonCrafting) {
t.Error("Item should be locked for crafting")
}
if item.IsItemLockedFor(LockReasonHouse) {
t.Error("Item should not be locked for house")
}
// Try to lock for another reason while already locked
if item.TryLockItem(LockReasonHouse) {
t.Error("Should not be able to lock for different reason")
}
// Unlock
if !item.TryUnlockItem(LockReasonCrafting) {
t.Error("Should be able to unlock item")
}
if item.IsItemLocked() {
t.Error("Item should not be locked after unlock")
}
}
func TestItemTypes(t *testing.T) {
item := NewItem()
// Test weapon
item.GenericInfo.ItemType = ItemTypeWeapon
if !item.IsWeapon() {
t.Error("Item should be a weapon")
}
if item.IsArmor() {
t.Error("Item should not be armor")
}
// Test armor
item.GenericInfo.ItemType = ItemTypeArmor
if !item.IsArmor() {
t.Error("Item should be armor")
}
if item.IsWeapon() {
t.Error("Item should not be a weapon")
}
// Test bag
item.GenericInfo.ItemType = ItemTypeBag
if !item.IsBag() {
t.Error("Item should be a bag")
}
// Test food
item.GenericInfo.ItemType = ItemTypeFood
item.FoodInfo = &FoodInfo{Type: 1} // Food
if !item.IsFood() {
t.Error("Item should be food")
}
if !item.IsFoodFood() {
t.Error("Item should be food (not drink)")
}
if item.IsFoodDrink() {
t.Error("Item should not be drink")
}
// Test drink
item.FoodInfo.Type = 0 // Drink
if !item.IsFoodDrink() {
t.Error("Item should be drink")
}
if item.IsFoodFood() {
t.Error("Item should not be food")
}
}
func TestMasterItemList(t *testing.T) {
masterList := NewMasterItemList()
if masterList == nil {
t.Fatal("NewMasterItemList returned nil")
}
// Initial state
if masterList.GetItemCount() != 0 {
t.Error("New master list should be empty")
}
// Add item
item := NewItem()
item.Name = "Test Item"
item.Details.ItemID = 12345
masterList.AddItem(item)
if masterList.GetItemCount() != 1 {
t.Errorf("Expected 1 item, got %d", masterList.GetItemCount())
}
// Get item
retrieved := masterList.GetItem(12345)
if retrieved == nil {
t.Fatal("GetItem returned nil")
}
if retrieved.Name != item.Name {
t.Errorf("Expected name %s, got %s", item.Name, retrieved.Name)
}
// Get by name
byName := masterList.GetItemByName("Test Item")
if byName == nil {
t.Fatal("GetItemByName returned nil")
}
if byName.Details.ItemID != item.Details.ItemID {
t.Errorf("Expected item ID %d, got %d", item.Details.ItemID, byName.Details.ItemID)
}
// Get non-existent item
nonExistent := masterList.GetItem(99999)
if nonExistent != nil {
t.Error("GetItem should return nil for non-existent item")
}
// Test stats
stats := masterList.GetStats()
if stats.TotalItems != 1 {
t.Errorf("Expected 1 total item, got %d", stats.TotalItems)
}
}
func TestMasterItemListStatMapping(t *testing.T) {
masterList := NewMasterItemList()
// Test getting stat ID by name
strID := masterList.GetItemStatIDByName("strength")
// ItemStatStr is 0, so we need to verify it was actually found
if strID != ItemStatStr {
t.Errorf("Expected strength stat ID %d, got %d", ItemStatStr, strID)
}
// Also test a stat that doesn't exist to ensure it returns a different value
nonExistentID := masterList.GetItemStatIDByName("nonexistent_stat")
if nonExistentID == ItemStatStr && ItemStatStr == 0 {
// This means we can't distinguish between "strength" and non-existent stats
// Let's verify strength is actually in the map
strName := masterList.GetItemStatNameByID(ItemStatStr)
if strName != "strength" {
t.Error("Strength stat mapping not found")
}
}
// Test getting stat name by ID
strName := masterList.GetItemStatNameByID(ItemStatStr)
if strName == "" {
t.Error("Should find stat name for STR")
}
// Add custom stat mapping
masterList.AddMappedItemStat(9999, "custom stat")
customID := masterList.GetItemStatIDByName("custom stat")
if customID != 9999 {
t.Errorf("Expected custom stat ID 9999, got %d", customID)
}
customName := masterList.GetItemStatNameByID(9999)
if customName != "custom stat" {
t.Errorf("Expected 'custom stat', got '%s'", customName)
}
}
func TestPlayerItemList(t *testing.T) {
t.Log("Starting TestPlayerItemList...")
playerList := NewPlayerItemList()
t.Log("NewPlayerItemList() completed")
if playerList == nil {
t.Fatal("NewPlayerItemList returned nil")
}
t.Log("PlayerItemList created successfully")
// Initial state
t.Log("Checking initial state...")
if playerList.GetNumberOfItems() != 0 {
t.Error("New player list should be empty")
}
t.Log("Initial state check completed")
// Create test item
t.Log("Creating test item...")
item := NewItem()
item.Name = "Player Item"
item.Details.ItemID = 1001
item.Details.BagID = 0
item.Details.SlotID = 0
t.Log("Test item created")
// Add item
t.Log("Adding item to player list...")
if !playerList.AddItem(item) {
t.Error("Should be able to add item")
}
t.Log("Item added successfully")
if playerList.GetNumberOfItems() != 1 {
t.Errorf("Expected 1 item, got %d", playerList.GetNumberOfItems())
}
// Get item
retrieved := playerList.GetItem(0, 0, BaseEquipment)
if retrieved == nil {
t.Fatal("GetItem returned nil")
}
if retrieved.Name != item.Name {
t.Errorf("Expected name %s, got %s", item.Name, retrieved.Name)
}
// Test HasItem
if !playerList.HasItem(1001, false) {
t.Error("Player should have item 1001")
}
if playerList.HasItem(9999, false) {
t.Error("Player should not have item 9999")
}
// Remove item
playerList.RemoveItem(item, true, true)
if playerList.GetNumberOfItems() != 0 {
t.Errorf("Expected 0 items after removal, got %d", playerList.GetNumberOfItems())
}
}
func TestPlayerItemListOverflow(t *testing.T) {
playerList := NewPlayerItemList()
// Add item to overflow
item := NewItem()
item.Name = "Overflow Item"
item.Details.ItemID = 2001
if !playerList.AddOverflowItem(item) {
t.Error("Should be able to add overflow item")
}
// Check overflow
overflowItem := playerList.GetOverflowItem()
if overflowItem == nil {
t.Fatal("GetOverflowItem returned nil")
}
if overflowItem.Name != item.Name {
t.Errorf("Expected name %s, got %s", item.Name, overflowItem.Name)
}
// Get all overflow items
overflowItems := playerList.GetOverflowItemList()
if len(overflowItems) != 1 {
t.Errorf("Expected 1 overflow item, got %d", len(overflowItems))
}
// Remove overflow item
playerList.RemoveOverflowItem(item)
overflowItems = playerList.GetOverflowItemList()
if len(overflowItems) != 0 {
t.Errorf("Expected 0 overflow items, got %d", len(overflowItems))
}
}
func TestEquipmentItemList(t *testing.T) {
equipment := NewEquipmentItemList()
if equipment == nil {
t.Fatal("NewEquipmentItemList returned nil")
}
// Initial state
if equipment.GetNumberOfItems() != 0 {
t.Error("New equipment list should be empty")
}
// Create test weapon
weapon := NewItem()
weapon.Name = "Test Sword"
weapon.Details.ItemID = 3001
weapon.GenericInfo.ItemType = ItemTypeWeapon
weapon.AddSlot(EQ2PrimarySlot)
// Equip weapon
if !equipment.AddItem(EQ2PrimarySlot, weapon) {
t.Error("Should be able to equip weapon")
}
if equipment.GetNumberOfItems() != 1 {
t.Errorf("Expected 1 equipped item, got %d", equipment.GetNumberOfItems())
}
// Get equipped weapon
equippedWeapon := equipment.GetItem(EQ2PrimarySlot)
if equippedWeapon == nil {
t.Fatal("GetItem returned nil")
}
if equippedWeapon.Name != weapon.Name {
t.Errorf("Expected name %s, got %s", weapon.Name, equippedWeapon.Name)
}
// Test equipment queries
if !equipment.HasItem(3001) {
t.Error("Equipment should have item 3001")
}
if !equipment.HasWeaponEquipped() {
t.Error("Equipment should have weapon equipped")
}
weapons := equipment.GetWeapons()
if len(weapons) != 1 {
t.Errorf("Expected 1 weapon, got %d", len(weapons))
}
// Remove weapon
equipment.RemoveItem(EQ2PrimarySlot, false)
if equipment.GetNumberOfItems() != 0 {
t.Errorf("Expected 0 items after removal, got %d", equipment.GetNumberOfItems())
}
}
func TestEquipmentValidation(t *testing.T) {
equipment := NewEquipmentItemList()
// Create invalid item (no name)
invalidItem := NewItem()
invalidItem.Details.ItemID = 4001
invalidItem.AddSlot(EQ2HeadSlot)
equipment.SetItem(EQ2HeadSlot, invalidItem, false)
result := equipment.ValidateEquipment()
if result.Valid {
t.Error("Equipment with invalid item should fail validation")
}
// Create item that can't be equipped in the slot
wrongSlotItem := NewItem()
wrongSlotItem.Name = "Wrong Slot Item"
wrongSlotItem.Details.ItemID = 4002
wrongSlotItem.AddSlot(EQ2ChestSlot) // Can only go in chest
equipment.SetItem(EQ2HeadSlot, wrongSlotItem, false)
result = equipment.ValidateEquipment()
if result.Valid {
t.Error("Equipment with wrong slot item should fail validation")
}
}
func TestItemSystemAdapter(t *testing.T) {
// Create dependencies
masterList := NewMasterItemList()
spellManager := NewMockSpellManager()
// Add a test spell
spellManager.AddMockSpell(1001, "Test Spell", 100, 1, "A test spell")
adapter := NewItemSystemAdapter(
masterList,
spellManager,
nil, // playerManager
nil, // packetManager
nil, // ruleManager
nil, // databaseService
nil, // questManager
nil, // brokerManager
nil, // craftingManager
nil, // housingManager
nil, // lootManager
)
if adapter == nil {
t.Fatal("NewItemSystemAdapter returned nil")
}
// Test stats
stats := adapter.GetSystemStats()
if stats == nil {
t.Error("GetSystemStats should not return nil")
}
totalTemplates, ok := stats["total_item_templates"].(int32)
if !ok || totalTemplates != 0 {
t.Errorf("Expected 0 total templates, got %v", stats["total_item_templates"])
}
}
func TestItemBrokerChecks(t *testing.T) {
masterList := NewMasterItemList()
// Create weapon
weapon := NewItem()
weapon.Name = "Test Weapon"
weapon.GenericInfo.ItemType = ItemTypeWeapon
weapon.AddSlot(EQ2PrimarySlot)
// Test broker type checks
if !masterList.ShouldAddItemBrokerSlot(weapon, ItemBrokerSlotPrimary) {
t.Error("Weapon should match primary slot broker type")
}
if masterList.ShouldAddItemBrokerSlot(weapon, ItemBrokerSlotHead) {
t.Error("Weapon should not match head slot broker type")
}
// Create armor with stats
armor := NewItem()
armor.Name = "Test Armor"
armor.GenericInfo.ItemType = ItemTypeArmor
armor.AddStat(&ItemStat{
StatName: "Strength",
StatType: ItemStatStr,
Value: 10,
})
if !masterList.ShouldAddItemBrokerStat(armor, ItemBrokerStatTypeStr) {
t.Error("Armor should match STR stat broker type")
}
if masterList.ShouldAddItemBrokerStat(armor, ItemBrokerStatTypeInt) {
t.Error("Armor should not match INT stat broker type")
}
}
func TestItemSearchCriteria(t *testing.T) {
masterList := NewMasterItemList()
// Add test items
sword := NewItem()
sword.Name = "Steel Sword"
sword.Details.ItemID = 5001
sword.Details.Tier = 1
sword.Details.RecommendedLevel = 10
sword.GenericInfo.ItemType = ItemTypeWeapon
sword.BrokerPrice = 1000
armor := NewItem()
armor.Name = "Iron Armor"
armor.Details.ItemID = 5002
armor.Details.Tier = 2
armor.Details.RecommendedLevel = 15
armor.GenericInfo.ItemType = ItemTypeArmor
armor.BrokerPrice = 2000
masterList.AddItem(sword)
masterList.AddItem(armor)
// Search by name
criteria := &ItemSearchCriteria{
Name: "sword",
}
results := masterList.GetItems(criteria)
if len(results) != 1 {
t.Errorf("Expected 1 result for sword search, got %d", len(results))
}
if results[0].Name != sword.Name {
t.Errorf("Expected %s, got %s", sword.Name, results[0].Name)
}
// Search by price range
criteria = &ItemSearchCriteria{
MinPrice: 1500,
MaxPrice: 2500,
}
results = masterList.GetItems(criteria)
if len(results) != 1 {
t.Errorf("Expected 1 result for price search, got %d", len(results))
}
if results[0].Name != armor.Name {
t.Errorf("Expected %s, got %s", armor.Name, results[0].Name)
}
// Search by tier
criteria = &ItemSearchCriteria{
MinTier: 2,
MaxTier: 2,
}
results = masterList.GetItems(criteria)
if len(results) != 1 {
t.Errorf("Expected 1 result for tier search, got %d", len(results))
}
// Search with no matches
criteria = &ItemSearchCriteria{
Name: "nonexistent",
}
results = masterList.GetItems(criteria)
if len(results) != 0 {
t.Errorf("Expected 0 results for nonexistent search, got %d", len(results))
}
}
func TestNextUniqueID(t *testing.T) {
id1 := NextUniqueID()
id2 := NextUniqueID()
if id1 == id2 {
t.Error("NextUniqueID should return different IDs")
}
if id2 != id1+1 {
t.Errorf("Expected ID2 to be ID1+1, got %d and %d", id1, id2)
}
}
func TestItemError(t *testing.T) {
err := NewItemError("test error")
if err == nil {
t.Fatal("NewItemError returned nil")
}
if err.Error() != "test error" {
t.Errorf("Expected 'test error', got '%s'", err.Error())
}
if !IsItemError(err) {
t.Error("Should identify as item error")
}
// Test with non-item error
if IsItemError(fmt.Errorf("not an item error")) {
t.Error("Should not identify as item error")
}
}
func TestConstants(t *testing.T) {
// Test slot constants
if EQ2PrimarySlot != 0 {
t.Errorf("Expected EQ2PrimarySlot to be 0, got %d", EQ2PrimarySlot)
}
if NumSlots != 25 {
t.Errorf("Expected NumSlots to be 25, got %d", NumSlots)
}
// Test item type constants
if ItemTypeWeapon != 1 {
t.Errorf("Expected ItemTypeWeapon to be 1, got %d", ItemTypeWeapon)
}
// Test flag constants
if Attuned != 1 {
t.Errorf("Expected Attuned to be 1, got %d", Attuned)
}
// Test stat constants
if ItemStatStr != 0 {
t.Errorf("Expected ItemStatStr to be 0, got %d", ItemStatStr)
}
}
func BenchmarkItemCreation(b *testing.B) {
for i := 0; i < b.N; i++ {
item := NewItem()
item.Name = "Benchmark Item"
item.Details.ItemID = int32(i)
}
}
func BenchmarkMasterItemListAccess(b *testing.B) {
masterList := NewMasterItemList()
// Add test items
for i := 0; i < 1000; i++ {
item := NewItem()
item.Name = fmt.Sprintf("Item %d", i)
item.Details.ItemID = int32(i + 1000)
masterList.AddItem(item)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
masterList.GetItem(int32((i % 1000) + 1000))
}
}
func BenchmarkPlayerItemListAdd(b *testing.B) {
playerList := NewPlayerItemList()
b.ResetTimer()
for i := 0; i < b.N; i++ {
item := NewItem()
item.Name = fmt.Sprintf("Item %d", i)
item.Details.ItemID = int32(i)
item.Details.BagID = int32(i % 6)
item.Details.SlotID = int16(i % 20)
playerList.AddItem(item)
}
}
func BenchmarkEquipmentBonusCalculation(b *testing.B) {
equipment := NewEquipmentItemList()
// Add some equipped items with stats
for slot := 0; slot < 10; slot++ {
item := NewItem()
item.Name = fmt.Sprintf("Equipment %d", slot)
item.Details.ItemID = int32(slot + 6000)
item.AddSlot(int8(slot))
// Add some stats
item.AddStat(&ItemStat{StatType: ItemStatStr, Value: 10})
item.AddStat(&ItemStat{StatType: ItemStatAgi, Value: 5})
item.AddStat(&ItemStat{StatType: ItemStatHealth, Value: 100})
equipment.SetItem(int8(slot), item, false)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
equipment.CalculateEquipmentBonuses()
}
}

View File

@ -1,357 +0,0 @@
# 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

@ -1,199 +0,0 @@
package loot
import (
"context"
"fmt"
"math/rand"
"testing"
"zombiezen.com/go/sqlite/sqlitex"
)
// setupTestDB creates a test database with minimal schema
func setupTestDB(t testing.TB) *sqlitex.Pool {
// Create unique database name to avoid test contamination
dbName := fmt.Sprintf("file:loot_test_%s_%d.db?mode=memory&cache=shared", t.Name(), rand.Int63())
pool, err := sqlitex.NewPool(dbName, sqlitex.PoolOptions{
PoolSize: 10,
})
if err != nil {
t.Fatalf("Failed to create test database pool: %v", err)
}
// Create complete test schema matching the real database structure
schema := `
CREATE TABLE loottable (
id INTEGER PRIMARY KEY,
name TEXT DEFAULT '',
mincoin INTEGER DEFAULT 0,
maxcoin INTEGER DEFAULT 0,
maxlootitems INTEGER DEFAULT 5,
lootdrop_probability REAL DEFAULT 100.0,
coin_probability REAL DEFAULT 75.0
);
CREATE TABLE lootdrop (
loot_table_id INTEGER,
item_id INTEGER,
item_charges INTEGER DEFAULT 1,
equip_item INTEGER DEFAULT 0,
probability REAL DEFAULT 25.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 DEFAULT 0,
value2 INTEGER DEFAULT 0,
value3 INTEGER DEFAULT 0,
value4 INTEGER DEFAULT 0
);
`
// Execute schema on connection
ctx := context.Background()
conn, err := pool.Take(ctx)
if err != nil {
t.Fatalf("Failed to get connection: %v", err)
}
defer pool.Put(conn)
if err := sqlitex.ExecuteScript(conn, schema, nil); err != nil {
t.Fatalf("Failed to create test schema: %v", err)
}
return pool
}
func TestNewLootDatabase(t *testing.T) {
pool := setupTestDB(t)
defer pool.Close()
lootDB := NewLootDatabase(pool)
if lootDB == nil {
t.Fatal("Expected non-nil LootDatabase")
}
if lootDB.pool == nil {
t.Fatal("Expected non-nil database pool")
}
}
func TestLootDatabaseBasicOperation(t *testing.T) {
pool := setupTestDB(t)
defer pool.Close()
lootDB := NewLootDatabase(pool)
// Test that LoadAllLootData doesn't crash (even with empty database)
err := lootDB.LoadAllLootData()
if err != nil {
t.Fatalf("LoadAllLootData should not fail with empty database: %v", err)
}
// Test that GetLootTable returns nil for non-existent table
table := lootDB.GetLootTable(999)
if table != nil {
t.Error("Expected nil for non-existent loot table")
}
}
func TestLootDatabaseWithData(t *testing.T) {
pool := setupTestDB(t)
defer pool.Close()
// Insert test data
ctx := context.Background()
conn, err := pool.Take(ctx)
if err != nil {
t.Fatalf("Failed to get connection: %v", err)
}
defer pool.Put(conn)
// Insert a test loot table
err = sqlitex.Execute(conn, `INSERT INTO loottable (id, name, mincoin, maxcoin, maxlootitems, lootdrop_probability, coin_probability) VALUES (?, ?, ?, ?, ?, ?, ?)`, &sqlitex.ExecOptions{
Args: []any{1, "Test Loot Table", 10, 50, 3, 75.0, 50.0},
})
if err != nil {
t.Fatalf("Failed to insert test loot table: %v", err)
}
// Insert test loot drops
err = sqlitex.Execute(conn, `INSERT INTO lootdrop (loot_table_id, item_id, item_charges, equip_item, probability, no_drop_quest_completed_id) VALUES (?, ?, ?, ?, ?, ?)`, &sqlitex.ExecOptions{
Args: []any{1, 101, 1, 0, 25.0, 0},
})
if err != nil {
t.Fatalf("Failed to insert test loot drop: %v", err)
}
// Insert spawn loot assignment
err = sqlitex.Execute(conn, `INSERT INTO spawn_loot (spawn_id, loottable_id) VALUES (?, ?)`, &sqlitex.ExecOptions{
Args: []any{1001, 1},
})
if err != nil {
t.Fatalf("Failed to insert spawn loot assignment: %v", err)
}
// Load all loot data
lootDB := NewLootDatabase(pool)
err = lootDB.LoadAllLootData()
if err != nil {
t.Fatalf("Failed to load loot data: %v", err)
}
// Verify loot table was 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 table.MinCoin != 10 {
t.Errorf("Expected min coin 10, got %d", table.MinCoin)
}
if table.MaxCoin != 50 {
t.Errorf("Expected max coin 50, got %d", table.MaxCoin)
}
if len(table.Drops) != 1 {
t.Errorf("Expected 1 loot drop, got %d", len(table.Drops))
} else {
drop := table.Drops[0]
if drop.ItemID != 101 {
t.Errorf("Expected item ID 101, got %d", drop.ItemID)
}
if drop.Probability != 25.0 {
t.Errorf("Expected probability 25.0, got %f", drop.Probability)
}
}
// Verify spawn loot assignment
tables := lootDB.GetSpawnLootTables(1001)
if len(tables) != 1 {
t.Errorf("Expected 1 loot table for spawn 1001, got %d", len(tables))
} else if tables[0] != 1 {
t.Errorf("Expected loot table ID 1 for spawn 1001, got %d", tables[0])
}
}
// Benchmark tests
func BenchmarkLootDatabaseCreation(b *testing.B) {
for i := 0; i < b.N; i++ {
pool := setupTestDB(b)
lootDB := NewLootDatabase(pool)
if lootDB == nil {
b.Fatal("Expected non-nil LootDatabase")
}
pool.Close()
}
}

View File

@ -1,321 +0,0 @@
# Languages Package
The languages package provides comprehensive multilingual character communication system for EverQuest II server emulation. It manages language learning, chat processing, and player language knowledge with thread-safe operations and efficient lookups.
## Features
- **Master Language Registry**: Global list of all available languages with ID-based and name-based lookups
- **Player Language Management**: Individual player language collections with learning/forgetting mechanics
- **Chat Processing**: Multilingual message processing with scrambling for unknown languages
- **Database Integration**: Language persistence with player-specific language knowledge
- **Thread Safety**: All operations use proper Go concurrency patterns with mutexes
- **Performance**: Optimized hash table lookups with benchmark results ~15ns per operation
- **EQ2 Compatibility**: Supports all standard EverQuest II racial languages
## Core Components
### Language Types
```go
// Core language representation
type Language struct {
id int32 // Unique language identifier
name string // Language name
saveNeeded bool // Whether this language needs database saving
mutex sync.RWMutex // Thread safety
}
// Master language registry (system-wide)
type MasterLanguagesList struct {
languages map[int32]*Language // Languages indexed by ID
nameIndex map[string]*Language // Languages indexed by name
mutex sync.RWMutex // Thread safety
}
// Player-specific language collection
type PlayerLanguagesList struct {
languages map[int32]*Language // Player's languages indexed by ID
nameIndex map[string]*Language // Player's languages indexed by name
mutex sync.RWMutex // Thread safety
}
```
### Management System
```go
// High-level language system manager
type Manager struct {
masterLanguagesList *MasterLanguagesList
database Database
logger Logger
// Statistics tracking
languageLookups int64
playersWithLanguages int64
languageUsageCount map[int32]int64
}
```
### Integration Interfaces
```go
// Entity interface for basic entity operations
type Entity interface {
GetID() int32
GetName() string
IsPlayer() bool
IsNPC() bool
IsBot() bool
}
// Player interface extending Entity for player-specific operations
type Player interface {
Entity
GetCharacterID() int32
SendMessage(message string)
}
// LanguageAware interface for entities with language capabilities
type LanguageAware interface {
GetKnownLanguages() *PlayerLanguagesList
KnowsLanguage(languageID int32) bool
GetPrimaryLanguage() int32
SetPrimaryLanguage(languageID int32)
CanUnderstand(languageID int32) bool
LearnLanguage(languageID int32) error
ForgetLanguage(languageID int32) error
}
```
## Standard EQ2 Languages
The package includes all standard EverQuest II racial languages:
```go
const (
LanguageIDCommon = 0 // Common tongue (default)
LanguageIDElvish = 1 // Elvish
LanguageIDDwarven = 2 // Dwarven
LanguageIDHalfling = 3 // Halfling
LanguageIDGnomish = 4 // Gnomish
LanguageIDIksar = 5 // Iksar
LanguageIDTrollish = 6 // Trollish
LanguageIDOgrish = 7 // Ogrish
LanguageIDFae = 8 // Fae
LanguageIDArasai = 9 // Arasai
LanguageIDSarnak = 10 // Sarnak
LanguageIDFroglok = 11 // Froglok
)
```
## Usage Examples
### Basic Setup
```go
// Create language system
database := NewMockDatabase() // or your database implementation
logger := NewMockLogger() // or your logger implementation
manager := NewManager(database, logger)
// Add standard EQ2 languages
languages := map[int32]string{
LanguageIDCommon: "Common",
LanguageIDElvish: "Elvish",
LanguageIDDwarven: "Dwarven",
// ... add other languages
}
for id, name := range languages {
lang := NewLanguage()
lang.SetID(id)
lang.SetName(name)
manager.AddLanguage(lang)
}
```
### Player Integration
```go
// Player struct implementing LanguageAware
type Player struct {
id int32
name string
languageAdapter *PlayerLanguageAdapter
}
func NewPlayer(id int32, name string, languageManager *Manager, logger Logger) *Player {
return &Player{
id: id,
name: name,
languageAdapter: NewPlayerLanguageAdapter(languageManager, logger),
}
}
// Implement LanguageAware interface by delegating to adapter
func (p *Player) GetKnownLanguages() *PlayerLanguagesList {
return p.languageAdapter.GetKnownLanguages()
}
func (p *Player) KnowsLanguage(languageID int32) bool {
return p.languageAdapter.KnowsLanguage(languageID)
}
func (p *Player) LearnLanguage(languageID int32) error {
return p.languageAdapter.LearnLanguage(languageID)
}
// ... implement other LanguageAware methods
```
### Chat Processing
```go
// Create chat processor
processor := NewChatLanguageProcessor(manager, logger)
// Process message from speaker
message := "Mae govannen!" // Elvish greeting
processed, err := processor.ProcessMessage(speaker, message, LanguageIDElvish)
if err != nil {
// Handle error (speaker doesn't know language, etc.)
}
// Filter message for listener based on their language knowledge
filtered := processor.FilterMessage(listener, processed, LanguageIDElvish)
// If listener doesn't know Elvish, message would be scrambled
// Language scrambling for unknown languages
scrambled := processor.GetLanguageSkramble(message, 0.0) // 0% comprehension
// Returns scrambled version: "Nhl nbchuulu!"
```
### Database Operations
```go
// Player learning languages with database persistence
player := NewPlayer(123, "TestPlayer", manager, logger)
// Learn a language
err := player.LearnLanguage(LanguageIDElvish)
if err != nil {
log.Printf("Failed to learn language: %v", err)
}
// Save to database
err = player.languageAdapter.SavePlayerLanguages(database, player.GetCharacterID())
if err != nil {
log.Printf("Failed to save languages: %v", err)
}
// Load from database (e.g., on player login)
newPlayer := NewPlayer(123, "TestPlayer", manager, logger)
err = newPlayer.languageAdapter.LoadPlayerLanguages(database, player.GetCharacterID())
if err != nil {
log.Printf("Failed to load languages: %v", err)
}
```
### Statistics and Management
```go
// Get system statistics
stats := manager.GetStatistics()
fmt.Printf("Total Languages: %d\\n", stats.TotalLanguages)
fmt.Printf("Players with Languages: %d\\n", stats.PlayersWithLanguages)
fmt.Printf("Language Lookups: %d\\n", stats.LanguageLookups)
// Process management commands
result, err := manager.ProcessCommand("stats", []string{})
if err != nil {
log.Printf("Command failed: %v", err)
} else {
fmt.Println(result)
}
// Validate all languages
issues := manager.ValidateAllLanguages()
if len(issues) > 0 {
log.Printf("Language validation issues: %v", issues)
}
```
## Database Interface
Implement the `Database` interface to provide persistence:
```go
type Database interface {
LoadAllLanguages() ([]*Language, error)
SaveLanguage(language *Language) error
DeleteLanguage(languageID int32) error
LoadPlayerLanguages(playerID int32) ([]*Language, error)
SavePlayerLanguage(playerID int32, languageID int32) error
DeletePlayerLanguage(playerID int32, languageID int32) error
}
```
## Performance
Benchmark results on AMD Ryzen 7 8845HS:
- **Language Lookup**: ~15ns per operation
- **Player Language Adapter**: ~8ns per operation
- **Integrated Operations**: ~16ns per operation
The system uses hash tables for O(1) lookups and is optimized for high-frequency operations.
## Thread Safety
All operations are thread-safe using:
- `sync.RWMutex` for read-heavy operations (language lookups)
- `sync.Mutex` for write operations and statistics
- Atomic operations where appropriate
## System Limits
```go
const (
MaxLanguagesPerPlayer = 100 // Reasonable limit per player
MaxTotalLanguages = 1000 // System-wide language limit
MaxLanguageNameLength = 50 // Maximum language name length
)
```
## Command Interface
The manager supports administrative commands:
- `stats` - Show language system statistics
- `list` - List all languages
- `info <id|name>` - Show language information
- `validate` - Validate all languages
- `reload` - Reload from database
- `add <id> <name>` - Add new language
- `remove <id>` - Remove language
- `search <term>` - Search languages by name
## Testing
Comprehensive test suite includes:
- Unit tests for all core components
- Integration tests with mock implementations
- Performance benchmarks
- Entity integration examples
- Thread safety validation
Run tests:
```bash
go test ./internal/languages -v
go test ./internal/languages -bench=.
```
## Integration Notes
1. **Entity Integration**: Players should embed `PlayerLanguageAdapter` and implement `LanguageAware`
2. **Database**: Implement the `Database` interface for persistence
3. **Logging**: Implement the `Logger` interface for system logging
4. **Chat System**: Use `ChatLanguageProcessor` for multilingual message handling
5. **Race Integration**: Initialize players with their racial languages at character creation
The languages package follows the same architectural patterns as other EQ2Go systems and integrates seamlessly with the entity, player, and chat systems.

View File

@ -1,392 +0,0 @@
package languages
import (
"fmt"
"testing"
)
// This file demonstrates how to integrate the languages package with entity systems
// ExamplePlayer shows how a Player struct might embed the language adapter
type ExamplePlayer struct {
id int32
name string
characterID int32
languageAdapter *PlayerLanguageAdapter
}
// NewExamplePlayer creates a new example player with language support
func NewExamplePlayer(id int32, name string, languageManager *Manager, logger Logger) *ExamplePlayer {
return &ExamplePlayer{
id: id,
name: name,
characterID: id, // In real implementation, this might be different
languageAdapter: NewPlayerLanguageAdapter(languageManager, logger),
}
}
// Implement the Entity interface
func (ep *ExamplePlayer) GetID() int32 { return ep.id }
func (ep *ExamplePlayer) GetName() string { return ep.name }
func (ep *ExamplePlayer) IsPlayer() bool { return true }
func (ep *ExamplePlayer) IsNPC() bool { return false }
func (ep *ExamplePlayer) IsBot() bool { return false }
// Implement the Player interface
func (ep *ExamplePlayer) GetCharacterID() int32 { return ep.characterID }
func (ep *ExamplePlayer) SendMessage(message string) {
// In real implementation, this would send a message to the client
}
// Implement the LanguageAware interface by delegating to the adapter
func (ep *ExamplePlayer) GetKnownLanguages() *PlayerLanguagesList {
return ep.languageAdapter.GetKnownLanguages()
}
func (ep *ExamplePlayer) KnowsLanguage(languageID int32) bool {
return ep.languageAdapter.KnowsLanguage(languageID)
}
func (ep *ExamplePlayer) GetPrimaryLanguage() int32 {
return ep.languageAdapter.GetPrimaryLanguage()
}
func (ep *ExamplePlayer) SetPrimaryLanguage(languageID int32) {
ep.languageAdapter.SetPrimaryLanguage(languageID)
}
func (ep *ExamplePlayer) CanUnderstand(languageID int32) bool {
return ep.languageAdapter.CanUnderstand(languageID)
}
func (ep *ExamplePlayer) LearnLanguage(languageID int32) error {
return ep.languageAdapter.LearnLanguage(languageID)
}
func (ep *ExamplePlayer) ForgetLanguage(languageID int32) error {
return ep.languageAdapter.ForgetLanguage(languageID)
}
// Database integration methods
func (ep *ExamplePlayer) LoadLanguagesFromDatabase(database Database) error {
return ep.languageAdapter.LoadPlayerLanguages(database, ep.characterID)
}
func (ep *ExamplePlayer) SaveLanguagesToDatabase(database Database) error {
return ep.languageAdapter.SavePlayerLanguages(database, ep.characterID)
}
// ExampleNPC shows how an NPC might implement basic language interfaces
type ExampleNPC struct {
id int32
name string
knownLanguageIDs []int32
primaryLanguage int32
}
func NewExampleNPC(id int32, name string, knownLanguages []int32) *ExampleNPC {
primaryLang := int32(LanguageIDCommon)
if len(knownLanguages) > 0 {
primaryLang = knownLanguages[0]
}
return &ExampleNPC{
id: id,
name: name,
knownLanguageIDs: knownLanguages,
primaryLanguage: primaryLang,
}
}
// Implement the Entity interface
func (en *ExampleNPC) GetID() int32 { return en.id }
func (en *ExampleNPC) GetName() string { return en.name }
func (en *ExampleNPC) IsPlayer() bool { return false }
func (en *ExampleNPC) IsNPC() bool { return true }
func (en *ExampleNPC) IsBot() bool { return false }
// Implement the Player interface (NPCs can also be treated as players for some language operations)
func (en *ExampleNPC) GetCharacterID() int32 { return en.id }
func (en *ExampleNPC) SendMessage(message string) {
// NPCs don't typically receive messages, but implement for interface compatibility
}
// Simple language support for NPCs (they don't learn/forget languages)
func (en *ExampleNPC) KnowsLanguage(languageID int32) bool {
for _, id := range en.knownLanguageIDs {
if id == languageID {
return true
}
}
return languageID == LanguageIDCommon // NPCs always understand common
}
func (en *ExampleNPC) GetPrimaryLanguage() int32 {
return en.primaryLanguage
}
func (en *ExampleNPC) CanUnderstand(languageID int32) bool {
return en.KnowsLanguage(languageID)
}
// TestEntityIntegration demonstrates how entities work with the language system
func TestEntityIntegration(t *testing.T) {
// Set up language system
database := NewMockDatabase()
logger := NewMockLogger()
manager := NewManager(database, logger)
// Add some languages
languages := []*Language{
createLanguage(LanguageIDCommon, "Common"),
createLanguage(LanguageIDElvish, "Elvish"),
createLanguage(LanguageIDDwarven, "Dwarven"),
createLanguage(LanguageIDOgrish, "Ogrish"),
}
for _, lang := range languages {
manager.AddLanguage(lang)
}
// Create a player
player := NewExamplePlayer(1, "TestPlayer", manager, logger)
// Player should know Common language by default
if !player.KnowsLanguage(LanguageIDCommon) {
t.Error("Player should know Common language by default")
}
// Test learning a language
err := player.LearnLanguage(LanguageIDElvish)
if err != nil {
t.Fatalf("Failed to learn Elvish: %v", err)
}
if !player.KnowsLanguage(LanguageIDElvish) {
t.Error("Player should know Elvish after learning")
}
// Test setting primary language
player.SetPrimaryLanguage(LanguageIDElvish)
if player.GetPrimaryLanguage() != LanguageIDElvish {
t.Error("Primary language should be Elvish")
}
// Test database persistence
err = player.SaveLanguagesToDatabase(database)
if err != nil {
t.Fatalf("Failed to save languages to database: %v", err)
}
// Create new player and load languages
newPlayer := NewExamplePlayer(1, "TestPlayer", manager, logger)
err = newPlayer.LoadLanguagesFromDatabase(database)
if err != nil {
t.Fatalf("Failed to load languages from database: %v", err)
}
if !newPlayer.KnowsLanguage(LanguageIDElvish) {
t.Error("New player should know Elvish after loading from database")
}
// Create an NPC with specific languages
npc := NewExampleNPC(100, "Elven Merchant", []int32{LanguageIDCommon, LanguageIDElvish})
if !npc.KnowsLanguage(LanguageIDElvish) {
t.Error("Elven NPC should know Elvish")
}
if npc.KnowsLanguage(LanguageIDDwarven) {
t.Error("Elven NPC should not know Dwarven")
}
// Test chat processing
processor := NewChatLanguageProcessor(manager, logger)
// Player speaking in Elvish
message := "Mae govannen!" // Elvish greeting
processed, err := processor.ProcessMessage(player, message, LanguageIDElvish)
if err != nil {
t.Fatalf("Failed to process Elvish message: %v", err)
}
if processed != message {
t.Errorf("Expected message '%s', got '%s'", message, processed)
}
// Test message filtering for understanding
filtered := processor.FilterMessage(npc, message, LanguageIDElvish)
if filtered != message {
t.Errorf("NPC should understand Elvish message, got '%s'", filtered)
}
}
// TestLanguageSystemIntegration demonstrates a complete language system workflow
func TestLanguageSystemIntegration(t *testing.T) {
// Set up a complete language system
database := NewMockDatabase()
logger := NewMockLogger()
// Create manager and initialize with standard EQ2 languages
manager := NewManager(database, logger)
eq2Languages := map[int32]string{
LanguageIDCommon: "Common",
LanguageIDElvish: "Elvish",
LanguageIDDwarven: "Dwarven",
LanguageIDHalfling: "Halfling",
LanguageIDGnomish: "Gnomish",
LanguageIDIksar: "Iksar",
LanguageIDTrollish: "Trollish",
LanguageIDOgrish: "Ogrish",
LanguageIDFae: "Fae",
LanguageIDArasai: "Arasai",
LanguageIDSarnak: "Sarnak",
LanguageIDFroglok: "Froglok",
}
for id, name := range eq2Languages {
lang := createLanguage(id, name)
manager.AddLanguage(lang)
}
if err := manager.Initialize(); err != nil {
t.Fatalf("Failed to initialize manager: %v", err)
}
// Create players of different races (represented by knowing different racial languages)
humanPlayer := NewExamplePlayer(1, "Human", manager, logger)
elfPlayer := NewExamplePlayer(2, "Elf", manager, logger)
dwarfPlayer := NewExamplePlayer(3, "Dwarf", manager, logger)
// Elven player learns Elvish at creation (racial language)
elfPlayer.LearnLanguage(LanguageIDElvish)
elfPlayer.SetPrimaryLanguage(LanguageIDElvish)
// Dwarven player learns Dwarven
dwarfPlayer.LearnLanguage(LanguageIDDwarven)
dwarfPlayer.SetPrimaryLanguage(LanguageIDDwarven)
// Test cross-racial communication
processor := NewChatLanguageProcessor(manager, logger)
// Elf speaks in Elvish
elvishMessage := "Elen sila lumenn omentielvo"
processed, _ := processor.ProcessMessage(elfPlayer, elvishMessage, LanguageIDElvish)
// Human doesn't understand Elvish (would be scrambled in real implementation)
humanHeard := processor.FilterMessage(humanPlayer, processed, LanguageIDElvish)
_ = humanHeard // In real implementation, this would be scrambled
// Dwarf doesn't understand Elvish either
dwarfHeard := processor.FilterMessage(dwarfPlayer, processed, LanguageIDElvish)
_ = dwarfHeard // In real implementation, this would be scrambled
// Everyone understands Common
commonMessage := "Hello everyone!"
processed, _ = processor.ProcessMessage(humanPlayer, commonMessage, LanguageIDCommon)
humanHeardCommon := processor.FilterMessage(humanPlayer, processed, LanguageIDCommon)
if humanHeardCommon != commonMessage {
t.Error("All players should understand Common")
}
elfHeardCommon := processor.FilterMessage(elfPlayer, processed, LanguageIDCommon)
if elfHeardCommon != commonMessage {
t.Error("All players should understand Common")
}
dwarfHeardCommon := processor.FilterMessage(dwarfPlayer, processed, LanguageIDCommon)
if dwarfHeardCommon != commonMessage {
t.Error("All players should understand Common")
}
// Test statistics
stats := manager.GetStatistics()
if stats.TotalLanguages != len(eq2Languages) {
t.Errorf("Expected %d languages, got %d", len(eq2Languages), stats.TotalLanguages)
}
// Test validation
issues := manager.ValidateAllLanguages()
if len(issues) > 0 {
t.Errorf("Expected no validation issues, got: %v", issues)
}
}
// Helper function to create languages
func createLanguage(id int32, name string) *Language {
lang := NewLanguage()
lang.SetID(id)
lang.SetName(name)
return lang
}
// TestLanguageAwareInterface demonstrates the LanguageAware interface usage
func TestLanguageAwareInterface(t *testing.T) {
database := NewMockDatabase()
logger := NewMockLogger()
manager := NewManager(database, logger)
// Add languages
manager.AddLanguage(createLanguage(LanguageIDCommon, "Common"))
manager.AddLanguage(createLanguage(LanguageIDElvish, "Elvish"))
// Create a player that implements LanguageAware
player := NewExamplePlayer(1, "TestPlayer", manager, logger)
// Test LanguageAware interface methods
var languageAware LanguageAware = player
// Should know Common by default
if !languageAware.KnowsLanguage(LanguageIDCommon) {
t.Error("LanguageAware entity should know Common by default")
}
// Learn a new language
err := languageAware.LearnLanguage(LanguageIDElvish)
if err != nil {
t.Fatalf("Failed to learn language through LanguageAware interface: %v", err)
}
// Check understanding
if !languageAware.CanUnderstand(LanguageIDElvish) {
t.Error("Should understand Elvish after learning")
}
// Set primary language
languageAware.SetPrimaryLanguage(LanguageIDElvish)
if languageAware.GetPrimaryLanguage() != LanguageIDElvish {
t.Error("Primary language should be Elvish")
}
// Get known languages
knownLanguages := languageAware.GetKnownLanguages()
if knownLanguages.Size() != 2 { // Common + Elvish
t.Errorf("Expected 2 known languages, got %d", knownLanguages.Size())
}
}
// Benchmark integration performance
func BenchmarkIntegratedPlayerLanguageOperations(b *testing.B) {
database := NewMockDatabase()
logger := NewMockLogger()
manager := NewManager(database, logger)
// Add languages
for i := 0; i < 50; i++ {
lang := createLanguage(int32(i), fmt.Sprintf("Language_%d", i))
manager.AddLanguage(lang)
}
player := NewExamplePlayer(1, "BenchmarkPlayer", manager, logger)
b.ResetTimer()
for i := 0; i < b.N; i++ {
langID := int32(i % 50)
if !player.KnowsLanguage(langID) {
player.LearnLanguage(langID)
}
player.CanUnderstand(langID)
}
}

View File

@ -1,704 +0,0 @@
package languages
import (
"fmt"
"testing"
)
// Mock implementations for testing
// MockDatabase implements the Database interface for testing
type MockDatabase struct {
languages map[int32]*Language
playerLanguages map[int32][]int32 // playerID -> []languageIDs
}
func NewMockDatabase() *MockDatabase {
return &MockDatabase{
languages: make(map[int32]*Language),
playerLanguages: make(map[int32][]int32),
}
}
func (md *MockDatabase) LoadAllLanguages() ([]*Language, error) {
var languages []*Language
for _, lang := range md.languages {
languages = append(languages, lang.Copy())
}
return languages, nil
}
func (md *MockDatabase) SaveLanguage(language *Language) error {
if language == nil {
return fmt.Errorf("language is nil")
}
md.languages[language.GetID()] = language.Copy()
return nil
}
func (md *MockDatabase) DeleteLanguage(languageID int32) error {
delete(md.languages, languageID)
return nil
}
func (md *MockDatabase) LoadPlayerLanguages(playerID int32) ([]*Language, error) {
var languages []*Language
if langIDs, exists := md.playerLanguages[playerID]; exists {
for _, langID := range langIDs {
if lang, exists := md.languages[langID]; exists {
languages = append(languages, lang.Copy())
}
}
}
return languages, nil
}
func (md *MockDatabase) SavePlayerLanguage(playerID int32, languageID int32) error {
if _, exists := md.languages[languageID]; !exists {
return fmt.Errorf("language %d does not exist", languageID)
}
if playerLangs, exists := md.playerLanguages[playerID]; exists {
// Check if already exists
for _, id := range playerLangs {
if id == languageID {
return nil // Already saved
}
}
md.playerLanguages[playerID] = append(playerLangs, languageID)
} else {
md.playerLanguages[playerID] = []int32{languageID}
}
return nil
}
func (md *MockDatabase) DeletePlayerLanguage(playerID int32, languageID int32) error {
if playerLangs, exists := md.playerLanguages[playerID]; exists {
for i, id := range playerLangs {
if id == languageID {
md.playerLanguages[playerID] = append(playerLangs[:i], playerLangs[i+1:]...)
return nil
}
}
}
return fmt.Errorf("player %d does not know language %d", playerID, languageID)
}
// MockLogger implements the Logger interface for testing
type MockLogger struct {
logs []string
}
func NewMockLogger() *MockLogger {
return &MockLogger{
logs: make([]string, 0),
}
}
func (ml *MockLogger) LogInfo(message string, args ...any) {
ml.logs = append(ml.logs, fmt.Sprintf("INFO: "+message, args...))
}
func (ml *MockLogger) LogError(message string, args ...any) {
ml.logs = append(ml.logs, fmt.Sprintf("ERROR: "+message, args...))
}
func (ml *MockLogger) LogDebug(message string, args ...any) {
ml.logs = append(ml.logs, fmt.Sprintf("DEBUG: "+message, args...))
}
func (ml *MockLogger) LogWarning(message string, args ...any) {
ml.logs = append(ml.logs, fmt.Sprintf("WARNING: "+message, args...))
}
func (ml *MockLogger) GetLogs() []string {
return ml.logs
}
func (ml *MockLogger) Clear() {
ml.logs = ml.logs[:0]
}
// MockPlayer implements the Player interface for testing
type MockPlayer struct {
id int32
name string
}
func NewMockPlayer(id int32, name string) *MockPlayer {
return &MockPlayer{
id: id,
name: name,
}
}
func (mp *MockPlayer) GetID() int32 { return mp.id }
func (mp *MockPlayer) GetName() string { return mp.name }
func (mp *MockPlayer) IsPlayer() bool { return true }
func (mp *MockPlayer) IsNPC() bool { return false }
func (mp *MockPlayer) IsBot() bool { return false }
func (mp *MockPlayer) GetCharacterID() int32 { return mp.id }
func (mp *MockPlayer) SendMessage(message string) {
// Mock implementation - could store messages for testing
}
// Test functions
func TestNewLanguage(t *testing.T) {
lang := NewLanguage()
if lang == nil {
t.Fatal("NewLanguage returned nil")
}
if lang.GetID() != 0 {
t.Errorf("Expected ID 0, got %d", lang.GetID())
}
if lang.GetName() != "" {
t.Errorf("Expected empty name, got %s", lang.GetName())
}
if lang.GetSaveNeeded() {
t.Error("New language should not need saving")
}
}
func TestLanguageSettersAndGetters(t *testing.T) {
lang := NewLanguage()
// Test ID
lang.SetID(123)
if lang.GetID() != 123 {
t.Errorf("Expected ID 123, got %d", lang.GetID())
}
// Test Name
lang.SetName("Test Language")
if lang.GetName() != "Test Language" {
t.Errorf("Expected name 'Test Language', got %s", lang.GetName())
}
// Test SaveNeeded
lang.SetSaveNeeded(true)
if !lang.GetSaveNeeded() {
t.Error("Expected save needed to be true")
}
}
func TestLanguageValidation(t *testing.T) {
lang := NewLanguage()
// Invalid - no name and ID 0
if lang.IsValid() {
t.Error("Empty language should not be valid")
}
// Set valid data
lang.SetID(LanguageIDElvish)
lang.SetName("Elvish")
if !lang.IsValid() {
t.Error("Language with valid ID and name should be valid")
}
// Test invalid ID
lang.SetID(-1)
if lang.IsValid() {
t.Error("Language with invalid ID should not be valid")
}
lang.SetID(MaxLanguageID + 1)
if lang.IsValid() {
t.Error("Language with ID exceeding max should not be valid")
}
}
func TestLanguageCopy(t *testing.T) {
original := NewLanguage()
original.SetID(42)
original.SetName("Original")
original.SetSaveNeeded(true)
copy := original.Copy()
if copy == nil {
t.Fatal("Copy returned nil")
}
if copy.GetID() != original.GetID() {
t.Errorf("Expected ID %d, got %d", original.GetID(), copy.GetID())
}
if copy.GetName() != original.GetName() {
t.Errorf("Expected name %s, got %s", original.GetName(), copy.GetName())
}
if copy.GetSaveNeeded() != original.GetSaveNeeded() {
t.Errorf("Expected save needed %v, got %v", original.GetSaveNeeded(), copy.GetSaveNeeded())
}
// Ensure it's a separate instance
copy.SetName("Modified")
if original.GetName() == copy.GetName() {
t.Error("Copy should be independent of original")
}
}
func TestMasterLanguagesList(t *testing.T) {
masterList := NewMasterLanguagesList()
// Test initial state
if masterList.Size() != 0 {
t.Errorf("Expected size 0, got %d", masterList.Size())
}
// Add language
lang := NewLanguage()
lang.SetID(LanguageIDCommon)
lang.SetName("Common")
err := masterList.AddLanguage(lang)
if err != nil {
t.Fatalf("Failed to add language: %v", err)
}
if masterList.Size() != 1 {
t.Errorf("Expected size 1, got %d", masterList.Size())
}
// Test retrieval
retrieved := masterList.GetLanguage(LanguageIDCommon)
if retrieved == nil {
t.Fatal("GetLanguage returned nil")
}
if retrieved.GetName() != "Common" {
t.Errorf("Expected name 'Common', got %s", retrieved.GetName())
}
// Test retrieval by name
byName := masterList.GetLanguageByName("Common")
if byName == nil {
t.Fatal("GetLanguageByName returned nil")
}
if byName.GetID() != LanguageIDCommon {
t.Errorf("Expected ID %d, got %d", LanguageIDCommon, byName.GetID())
}
// Test duplicate ID
dupLang := NewLanguage()
dupLang.SetID(LanguageIDCommon)
dupLang.SetName("Duplicate")
err = masterList.AddLanguage(dupLang)
if err == nil {
t.Error("Should not allow duplicate language ID")
}
// Test duplicate name
dupNameLang := NewLanguage()
dupNameLang.SetID(LanguageIDElvish)
dupNameLang.SetName("Common")
err = masterList.AddLanguage(dupNameLang)
if err == nil {
t.Error("Should not allow duplicate language name")
}
}
func TestPlayerLanguagesList(t *testing.T) {
playerList := NewPlayerLanguagesList()
// Test initial state
if playerList.Size() != 0 {
t.Errorf("Expected size 0, got %d", playerList.Size())
}
// Add language
lang := NewLanguage()
lang.SetID(LanguageIDCommon)
lang.SetName("Common")
err := playerList.Add(lang)
if err != nil {
t.Fatalf("Failed to add language: %v", err)
}
if playerList.Size() != 1 {
t.Errorf("Expected size 1, got %d", playerList.Size())
}
// Test has language
if !playerList.HasLanguage(LanguageIDCommon) {
t.Error("Player should have Common language")
}
if playerList.HasLanguage(LanguageIDElvish) {
t.Error("Player should not have Elvish language")
}
// Test removal
if !playerList.RemoveLanguage(LanguageIDCommon) {
t.Error("Should be able to remove Common language")
}
if playerList.Size() != 0 {
t.Errorf("Expected size 0 after removal, got %d", playerList.Size())
}
}
func TestManager(t *testing.T) {
database := NewMockDatabase()
logger := NewMockLogger()
// Pre-populate database with some languages
commonLang := NewLanguage()
commonLang.SetID(LanguageIDCommon)
commonLang.SetName("Common")
database.SaveLanguage(commonLang)
elvishLang := NewLanguage()
elvishLang.SetID(LanguageIDElvish)
elvishLang.SetName("Elvish")
database.SaveLanguage(elvishLang)
manager := NewManager(database, logger)
// Test initialization
err := manager.Initialize()
if err != nil {
t.Fatalf("Failed to initialize manager: %v", err)
}
if manager.GetLanguageCount() != 2 {
t.Errorf("Expected 2 languages, got %d", manager.GetLanguageCount())
}
// Test language retrieval
lang := manager.GetLanguage(LanguageIDCommon)
if lang == nil {
t.Fatal("GetLanguage returned nil for Common")
}
if lang.GetName() != "Common" {
t.Errorf("Expected name 'Common', got %s", lang.GetName())
}
// Test language retrieval by name
langByName := manager.GetLanguageByName("Elvish")
if langByName == nil {
t.Fatal("GetLanguageByName returned nil for Elvish")
}
if langByName.GetID() != LanguageIDElvish {
t.Errorf("Expected ID %d, got %d", LanguageIDElvish, langByName.GetID())
}
// Test adding new language
dwarvenLang := NewLanguage()
dwarvenLang.SetID(LanguageIDDwarven)
dwarvenLang.SetName("Dwarven")
err = manager.AddLanguage(dwarvenLang)
if err != nil {
t.Fatalf("Failed to add language: %v", err)
}
if manager.GetLanguageCount() != 3 {
t.Errorf("Expected 3 languages after adding, got %d", manager.GetLanguageCount())
}
// Test statistics
stats := manager.GetStatistics()
if stats.TotalLanguages != 3 {
t.Errorf("Expected 3 total languages in stats, got %d", stats.TotalLanguages)
}
}
func TestPlayerLanguageAdapter(t *testing.T) {
database := NewMockDatabase()
logger := NewMockLogger()
// Set up manager with languages
manager := NewManager(database, logger)
commonLang := NewLanguage()
commonLang.SetID(LanguageIDCommon)
commonLang.SetName("Common")
manager.AddLanguage(commonLang)
elvishLang := NewLanguage()
elvishLang.SetID(LanguageIDElvish)
elvishLang.SetName("Elvish")
manager.AddLanguage(elvishLang)
// Create adapter
adapter := NewPlayerLanguageAdapter(manager, logger)
// Test initial state - should know Common by default
if !adapter.KnowsLanguage(LanguageIDCommon) {
t.Error("Adapter should know Common language by default")
}
if adapter.GetPrimaryLanguage() != LanguageIDCommon {
t.Errorf("Expected primary language to be Common (%d), got %d", LanguageIDCommon, adapter.GetPrimaryLanguage())
}
// Test learning a new language
err := adapter.LearnLanguage(LanguageIDElvish)
if err != nil {
t.Fatalf("Failed to learn Elvish: %v", err)
}
if !adapter.KnowsLanguage(LanguageIDElvish) {
t.Error("Should know Elvish after learning")
}
// Test setting primary language
adapter.SetPrimaryLanguage(LanguageIDElvish)
if adapter.GetPrimaryLanguage() != LanguageIDElvish {
t.Errorf("Expected primary language to be Elvish (%d), got %d", LanguageIDElvish, adapter.GetPrimaryLanguage())
}
// Test forgetting a language
err = adapter.ForgetLanguage(LanguageIDElvish)
if err != nil {
t.Fatalf("Failed to forget Elvish: %v", err)
}
if adapter.KnowsLanguage(LanguageIDElvish) {
t.Error("Should not know Elvish after forgetting")
}
// Primary language should reset to Common after forgetting
if adapter.GetPrimaryLanguage() != LanguageIDCommon {
t.Errorf("Expected primary language to reset to Common (%d), got %d", LanguageIDCommon, adapter.GetPrimaryLanguage())
}
// Test cannot forget Common
err = adapter.ForgetLanguage(LanguageIDCommon)
if err == nil {
t.Error("Should not be able to forget Common language")
}
}
func TestPlayerLanguageAdapterDatabase(t *testing.T) {
database := NewMockDatabase()
logger := NewMockLogger()
// Set up manager with languages
manager := NewManager(database, logger)
commonLang := NewLanguage()
commonLang.SetID(LanguageIDCommon)
commonLang.SetName("Common")
manager.AddLanguage(commonLang)
elvishLang := NewLanguage()
elvishLang.SetID(LanguageIDElvish)
elvishLang.SetName("Elvish")
manager.AddLanguage(elvishLang)
// Pre-populate database for player
playerID := int32(123)
database.SavePlayerLanguage(playerID, LanguageIDCommon)
database.SavePlayerLanguage(playerID, LanguageIDElvish)
// Create adapter and load from database
adapter := NewPlayerLanguageAdapter(manager, logger)
err := adapter.LoadPlayerLanguages(database, playerID)
if err != nil {
t.Fatalf("Failed to load player languages: %v", err)
}
// Should know both languages
if !adapter.KnowsLanguage(LanguageIDCommon) {
t.Error("Should know Common after loading from database")
}
if !adapter.KnowsLanguage(LanguageIDElvish) {
t.Error("Should know Elvish after loading from database")
}
// Learn a new language and save
dwarvenLang := NewLanguage()
dwarvenLang.SetID(LanguageIDDwarven)
dwarvenLang.SetName("Dwarven")
manager.AddLanguage(dwarvenLang)
err = adapter.LearnLanguage(LanguageIDDwarven)
if err != nil {
t.Fatalf("Failed to learn Dwarven: %v", err)
}
err = adapter.SavePlayerLanguages(database, playerID)
if err != nil {
t.Fatalf("Failed to save player languages: %v", err)
}
// Create new adapter and load - should have all three languages
newAdapter := NewPlayerLanguageAdapter(manager, logger)
err = newAdapter.LoadPlayerLanguages(database, playerID)
if err != nil {
t.Fatalf("Failed to load player languages in new adapter: %v", err)
}
if !newAdapter.KnowsLanguage(LanguageIDDwarven) {
t.Error("New adapter should know Dwarven after loading from database")
}
}
func TestChatLanguageProcessor(t *testing.T) {
database := NewMockDatabase()
logger := NewMockLogger()
manager := NewManager(database, logger)
commonLang := NewLanguage()
commonLang.SetID(LanguageIDCommon)
commonLang.SetName("Common")
manager.AddLanguage(commonLang)
processor := NewChatLanguageProcessor(manager, logger)
player := NewMockPlayer(123, "TestPlayer")
message := "Hello, world!"
// Test processing message in Common language
processed, err := processor.ProcessMessage(player, message, LanguageIDCommon)
if err != nil {
t.Fatalf("Failed to process message: %v", err)
}
if processed != message {
t.Errorf("Expected processed message '%s', got '%s'", message, processed)
}
// Test processing message in non-existent language
_, err = processor.ProcessMessage(player, message, 9999)
if err == nil {
t.Error("Should fail to process message in non-existent language")
}
// Test filter message (currently always returns original message)
filtered := processor.FilterMessage(player, message, LanguageIDCommon)
if filtered != message {
t.Errorf("Expected filtered message '%s', got '%s'", message, filtered)
}
}
func TestLanguageSkramble(t *testing.T) {
processor := NewChatLanguageProcessor(nil, nil)
message := "Hello World"
// Test full comprehension
scrambled := processor.GetLanguageSkramble(message, 1.0)
if scrambled != message {
t.Errorf("Full comprehension should return original message, got '%s'", scrambled)
}
// Test no comprehension
scrambled = processor.GetLanguageSkramble(message, 0.0)
if scrambled == message {
t.Error("No comprehension should scramble the message")
}
// Should preserve spaces
if len(scrambled) != len(message) {
t.Errorf("Scrambled message should have same length: expected %d, got %d", len(message), len(scrambled))
}
}
func TestManagerCommands(t *testing.T) {
database := NewMockDatabase()
logger := NewMockLogger()
manager := NewManager(database, logger)
commonLang := NewLanguage()
commonLang.SetID(LanguageIDCommon)
commonLang.SetName("Common")
manager.AddLanguage(commonLang)
// Test stats command
result, err := manager.ProcessCommand("stats", []string{})
if err != nil {
t.Fatalf("Stats command failed: %v", err)
}
if result == "" {
t.Error("Stats command should return non-empty result")
}
// Test list command
result, err = manager.ProcessCommand("list", []string{})
if err != nil {
t.Fatalf("List command failed: %v", err)
}
if result == "" {
t.Error("List command should return non-empty result")
}
// Test info command
result, err = manager.ProcessCommand("info", []string{"0"})
if err != nil {
t.Fatalf("Info command failed: %v", err)
}
if result == "" {
t.Error("Info command should return non-empty result")
}
// Test unknown command
_, err = manager.ProcessCommand("unknown", []string{})
if err == nil {
t.Error("Unknown command should return error")
}
}
func BenchmarkLanguageLookup(b *testing.B) {
database := NewMockDatabase()
logger := NewMockLogger()
manager := NewManager(database, logger)
// Add many languages
for i := 0; i < 1000; i++ {
lang := NewLanguage()
lang.SetID(int32(i))
lang.SetName(fmt.Sprintf("Language_%d", i))
manager.AddLanguage(lang)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
manager.GetLanguage(int32(i % 1000))
}
}
func BenchmarkPlayerLanguageAdapter(b *testing.B) {
database := NewMockDatabase()
logger := NewMockLogger()
manager := NewManager(database, logger)
// Add some languages
for i := 0; i < 100; i++ {
lang := NewLanguage()
lang.SetID(int32(i))
lang.SetName(fmt.Sprintf("Language_%d", i))
manager.AddLanguage(lang)
}
adapter := NewPlayerLanguageAdapter(manager, logger)
b.ResetTimer()
for i := 0; i < b.N; i++ {
languageID := int32(i % 100)
if !adapter.KnowsLanguage(languageID) {
adapter.LearnLanguage(languageID)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,767 +0,0 @@
package npc
import (
"fmt"
"strings"
"testing"
)
// Mock implementations for testing
// MockDatabase implements the Database interface for testing
type MockDatabase struct {
npcs map[int32]*NPC
spells map[int32][]*NPCSpell
skills map[int32]map[string]*Skill
created bool
}
func NewMockDatabase() *MockDatabase {
return &MockDatabase{
npcs: make(map[int32]*NPC),
spells: make(map[int32][]*NPCSpell),
skills: make(map[int32]map[string]*Skill),
created: false,
}
}
func (md *MockDatabase) LoadAllNPCs() ([]*NPC, error) {
var npcs []*NPC
for _, npc := range md.npcs {
// Create a copy to avoid modifying the stored version
npcCopy := NewNPCFromExisting(npc)
npcs = append(npcs, npcCopy)
}
return npcs, nil
}
func (md *MockDatabase) SaveNPC(npc *NPC) error {
if npc == nil || !npc.IsValid() {
return fmt.Errorf("invalid NPC")
}
md.npcs[npc.GetNPCID()] = NewNPCFromExisting(npc)
return nil
}
func (md *MockDatabase) DeleteNPC(npcID int32) error {
if _, exists := md.npcs[npcID]; !exists {
return fmt.Errorf("NPC with ID %d not found", npcID)
}
delete(md.npcs, npcID)
delete(md.spells, npcID)
delete(md.skills, npcID)
return nil
}
func (md *MockDatabase) LoadNPCSpells(npcID int32) ([]*NPCSpell, error) {
if spells, exists := md.spells[npcID]; exists {
var result []*NPCSpell
for _, spell := range spells {
result = append(result, spell.Copy())
}
return result, nil
}
return []*NPCSpell{}, nil
}
func (md *MockDatabase) SaveNPCSpells(npcID int32, spells []*NPCSpell) error {
var spellCopies []*NPCSpell
for _, spell := range spells {
if spell != nil {
spellCopies = append(spellCopies, spell.Copy())
}
}
md.spells[npcID] = spellCopies
return nil
}
func (md *MockDatabase) LoadNPCSkills(npcID int32) (map[string]*Skill, error) {
if skills, exists := md.skills[npcID]; exists {
result := make(map[string]*Skill)
for name, skill := range skills {
result[name] = NewSkill(skill.SkillID, skill.Name, skill.GetCurrentVal(), skill.MaxVal)
}
return result, nil
}
return make(map[string]*Skill), nil
}
func (md *MockDatabase) SaveNPCSkills(npcID int32, skills map[string]*Skill) error {
skillCopies := make(map[string]*Skill)
for name, skill := range skills {
if skill != nil {
skillCopies[name] = NewSkill(skill.SkillID, skill.Name, skill.GetCurrentVal(), skill.MaxVal)
}
}
md.skills[npcID] = skillCopies
return nil
}
// MockLogger implements the Logger interface for testing
type MockLogger struct {
logs []string
}
func NewMockLogger() *MockLogger {
return &MockLogger{
logs: make([]string, 0),
}
}
func (ml *MockLogger) LogInfo(message string, args ...any) {
ml.logs = append(ml.logs, fmt.Sprintf("INFO: "+message, args...))
}
func (ml *MockLogger) LogError(message string, args ...any) {
ml.logs = append(ml.logs, fmt.Sprintf("ERROR: "+message, args...))
}
func (ml *MockLogger) LogDebug(message string, args ...any) {
ml.logs = append(ml.logs, fmt.Sprintf("DEBUG: "+message, args...))
}
func (ml *MockLogger) LogWarning(message string, args ...any) {
ml.logs = append(ml.logs, fmt.Sprintf("WARNING: "+message, args...))
}
func (ml *MockLogger) GetLogs() []string {
return ml.logs
}
func (ml *MockLogger) Clear() {
ml.logs = ml.logs[:0]
}
// Test functions
func TestNewNPC(t *testing.T) {
npc := NewNPC()
if npc == nil {
t.Fatal("NewNPC returned nil")
}
if npc.Entity == nil {
t.Error("NPC should have an Entity")
}
if npc.GetNPCID() != 0 {
t.Errorf("Expected NPC ID 0, got %d", npc.GetNPCID())
}
if npc.GetAIStrategy() != AIStrategyBalanced {
t.Errorf("Expected AI strategy %d, got %d", AIStrategyBalanced, npc.GetAIStrategy())
}
if npc.GetAggroRadius() != DefaultAggroRadius {
t.Errorf("Expected aggro radius %f, got %f", DefaultAggroRadius, npc.GetAggroRadius())
}
if npc.GetBrain() == nil {
t.Error("NPC should have a brain")
}
}
func TestNPCBasicProperties(t *testing.T) {
npc := NewNPC()
// Test NPC ID
testNPCID := int32(12345)
npc.SetNPCID(testNPCID)
if npc.GetNPCID() != testNPCID {
t.Errorf("Expected NPC ID %d, got %d", testNPCID, npc.GetNPCID())
}
// Test AI Strategy
npc.SetAIStrategy(AIStrategyOffensive)
if npc.GetAIStrategy() != AIStrategyOffensive {
t.Errorf("Expected AI strategy %d, got %d", AIStrategyOffensive, npc.GetAIStrategy())
}
// Test Aggro Radius
testRadius := float32(25.5)
npc.SetAggroRadius(testRadius, false)
if npc.GetAggroRadius() != testRadius {
t.Errorf("Expected aggro radius %f, got %f", testRadius, npc.GetAggroRadius())
}
// Test Appearance ID
testAppearanceID := int32(5432)
npc.SetAppearanceID(testAppearanceID)
if npc.GetAppearanceID() != testAppearanceID {
t.Errorf("Expected appearance ID %d, got %d", testAppearanceID, npc.GetAppearanceID())
}
}
func TestNPCEntityIntegration(t *testing.T) {
npc := NewNPC()
if npc.Entity == nil {
t.Fatal("NPC should have an Entity")
}
// Test entity properties through NPC
testName := "Test NPC"
npc.Entity.SetName(testName)
// Trim the name to handle fixed-size array padding
retrievedName := strings.TrimRight(npc.Entity.GetName(), "\x00")
if retrievedName != testName {
t.Errorf("Expected name '%s', got '%s'", testName, retrievedName)
}
// Test level through InfoStruct since Entity doesn't have SetLevel
testLevel := int16(25)
if npc.Entity.GetInfoStruct() != nil {
npc.Entity.GetInfoStruct().SetLevel(testLevel)
if npc.Entity.GetLevel() != int8(testLevel) {
t.Errorf("Expected level %d, got %d", testLevel, npc.Entity.GetLevel())
}
}
testHP := int32(1500)
npc.Entity.SetHP(testHP)
if npc.Entity.GetHP() != testHP {
t.Errorf("Expected HP %d, got %d", testHP, npc.Entity.GetHP())
}
}
func TestNPCSpells(t *testing.T) {
npc := NewNPC()
// Test initial spell state
if npc.HasSpells() {
t.Error("New NPC should not have spells")
}
if len(npc.GetSpells()) != 0 {
t.Errorf("Expected 0 spells, got %d", len(npc.GetSpells()))
}
// Create test spells (without cast-on flags so they go into main spells array)
spell1 := NewNPCSpell()
spell1.SetSpellID(100)
spell1.SetTier(1)
spell2 := NewNPCSpell()
spell2.SetSpellID(200)
spell2.SetTier(2)
spells := []*NPCSpell{spell1, spell2}
npc.SetSpells(spells)
// Test spell retrieval
retrievedSpells := npc.GetSpells()
if len(retrievedSpells) != 2 {
t.Errorf("Expected 2 spells, got %d", len(retrievedSpells))
}
if npc.HasSpells() != true {
t.Error("NPC should have spells after setting them")
}
}
func TestNPCSkills(t *testing.T) {
npc := NewNPC()
// Create test skills
skill1 := NewSkill(1, "Sword", 50, 100)
skill2 := NewSkill(2, "Shield", 75, 100)
skills := map[string]*Skill{
"Sword": skill1,
"Shield": skill2,
}
npc.SetSkills(skills)
// Test skill retrieval by name
retrievedSkill := npc.GetSkillByName("Sword", false)
if retrievedSkill == nil {
t.Fatal("Should retrieve Sword skill")
}
if retrievedSkill.GetCurrentVal() != 50 {
t.Errorf("Expected skill value 50, got %d", retrievedSkill.GetCurrentVal())
}
// Test non-existent skill
nonExistentSkill := npc.GetSkillByName("Magic", false)
if nonExistentSkill != nil {
t.Error("Should not retrieve non-existent skill")
}
}
func TestNPCRunback(t *testing.T) {
npc := NewNPC()
// Test initial runback state
if npc.GetRunbackLocation() != nil {
t.Error("New NPC should not have runback location")
}
if npc.IsRunningBack() {
t.Error("New NPC should not be running back")
}
// Set runback location
testX, testY, testZ := float32(10.5), float32(20.3), float32(30.7)
testGridID := int32(12)
npc.SetRunbackLocation(testX, testY, testZ, testGridID, true)
runbackLoc := npc.GetRunbackLocation()
if runbackLoc == nil {
t.Fatal("Should have runback location after setting")
}
if runbackLoc.X != testX || runbackLoc.Y != testY || runbackLoc.Z != testZ {
t.Errorf("Runback location mismatch: expected (%f,%f,%f), got (%f,%f,%f)",
testX, testY, testZ, runbackLoc.X, runbackLoc.Y, runbackLoc.Z)
}
if runbackLoc.GridID != testGridID {
t.Errorf("Expected grid ID %d, got %d", testGridID, runbackLoc.GridID)
}
// Test clearing runback
npc.ClearRunback()
if npc.GetRunbackLocation() != nil {
t.Error("Runback location should be cleared")
}
}
func TestNPCMovementTimer(t *testing.T) {
npc := NewNPC()
// Test initial timer state
if npc.IsPauseMovementTimerActive() {
t.Error("Movement timer should not be active initially")
}
// Test pausing movement
if !npc.PauseMovement(100) {
t.Error("Should be able to pause movement")
}
// Note: Timer might not be immediately active due to implementation details
// The test focuses on the API being callable without errors
}
func TestNPCBrain(t *testing.T) {
npc := NewNPC()
// Test default brain
brain := npc.GetBrain()
if brain == nil {
t.Fatal("NPC should have a default brain")
}
if !brain.IsActive() {
t.Error("Default brain should be active")
}
if brain.GetBody() != npc {
t.Error("Brain should reference the NPC")
}
// Test brain thinking (should not error)
err := brain.Think()
if err != nil {
t.Errorf("Brain thinking should not error: %v", err)
}
// Test setting brain inactive
brain.SetActive(false)
if brain.IsActive() {
t.Error("Brain should be inactive after setting to false")
}
}
func TestNPCValidation(t *testing.T) {
npc := NewNPC()
// Set a valid level for the NPC to pass validation
if npc.Entity != nil && npc.Entity.GetInfoStruct() != nil {
npc.Entity.GetInfoStruct().SetLevel(10) // Valid level between 1-100
}
// NPC should be valid if it has an entity with valid level
if !npc.IsValid() {
t.Error("NPC with valid level should be valid")
}
// Test NPC without entity
npc.Entity = nil
if npc.IsValid() {
t.Error("NPC without entity should not be valid")
}
}
func TestNPCString(t *testing.T) {
npc := NewNPC()
npc.SetNPCID(123)
if npc.Entity != nil {
npc.Entity.SetName("Test NPC")
}
str := npc.String()
if str == "" {
t.Error("NPC string representation should not be empty")
}
}
func TestNPCCopyFromExisting(t *testing.T) {
// Create original NPC
originalNPC := NewNPC()
originalNPC.SetNPCID(100)
originalNPC.SetAIStrategy(AIStrategyDefensive)
originalNPC.SetAggroRadius(30.0, false)
if originalNPC.Entity != nil {
originalNPC.Entity.SetName("Original NPC")
if originalNPC.Entity.GetInfoStruct() != nil {
originalNPC.Entity.GetInfoStruct().SetLevel(10)
}
}
// Create copy
copiedNPC := NewNPCFromExisting(originalNPC)
if copiedNPC == nil {
t.Fatal("NewNPCFromExisting returned nil")
}
// Verify copy has same properties
if copiedNPC.GetNPCID() != originalNPC.GetNPCID() {
t.Errorf("NPC ID mismatch: expected %d, got %d", originalNPC.GetNPCID(), copiedNPC.GetNPCID())
}
if copiedNPC.GetAIStrategy() != originalNPC.GetAIStrategy() {
t.Errorf("AI strategy mismatch: expected %d, got %d", originalNPC.GetAIStrategy(), copiedNPC.GetAIStrategy())
}
// Test copying from nil
nilCopy := NewNPCFromExisting(nil)
if nilCopy == nil {
t.Error("NewNPCFromExisting(nil) should return a new NPC, not nil")
}
}
func TestNPCCombat(t *testing.T) {
npc := NewNPC()
// Test combat state
npc.InCombat(true)
// Note: The actual combat state checking would depend on Entity implementation
// Test combat processing (should not error)
npc.ProcessCombat()
}
func TestNPCShardSystem(t *testing.T) {
npc := NewNPC()
// Test shard properties
testShardID := int32(5)
npc.SetShardID(testShardID)
if npc.GetShardID() != testShardID {
t.Errorf("Expected shard ID %d, got %d", testShardID, npc.GetShardID())
}
testCharID := int32(12345)
npc.SetShardCharID(testCharID)
if npc.GetShardCharID() != testCharID {
t.Errorf("Expected shard char ID %d, got %d", testCharID, npc.GetShardCharID())
}
testTimestamp := int64(1609459200) // 2021-01-01 00:00:00 UTC
npc.SetShardCreatedTimestamp(testTimestamp)
if npc.GetShardCreatedTimestamp() != testTimestamp {
t.Errorf("Expected timestamp %d, got %d", testTimestamp, npc.GetShardCreatedTimestamp())
}
}
func TestNPCSkillBonuses(t *testing.T) {
npc := NewNPC()
// Test adding skill bonus
spellID := int32(500)
skillID := int32(10)
bonusValue := float32(15.5)
npc.AddSkillBonus(spellID, skillID, bonusValue)
// Test removing skill bonus
npc.RemoveSkillBonus(spellID)
}
func TestNPCSpellTypes(t *testing.T) {
// Test NPCSpell creation and methods
spell := NewNPCSpell()
if spell == nil {
t.Fatal("NewNPCSpell returned nil")
}
// Test default values
if spell.GetListID() != 0 {
t.Errorf("Expected list ID 0, got %d", spell.GetListID())
}
if spell.GetTier() != 1 {
t.Errorf("Expected tier 1, got %d", spell.GetTier())
}
// Test setters and getters
testSpellID := int32(12345)
spell.SetSpellID(testSpellID)
if spell.GetSpellID() != testSpellID {
t.Errorf("Expected spell ID %d, got %d", testSpellID, spell.GetSpellID())
}
testTier := int8(5)
spell.SetTier(testTier)
if spell.GetTier() != testTier {
t.Errorf("Expected tier %d, got %d", testTier, spell.GetTier())
}
// Test boolean properties
spell.SetCastOnSpawn(true)
if !spell.GetCastOnSpawn() {
t.Error("Expected cast on spawn to be true")
}
spell.SetCastOnInitialAggro(true)
if !spell.GetCastOnInitialAggro() {
t.Error("Expected cast on initial aggro to be true")
}
// Test HP ratio
testRatio := int8(75)
spell.SetRequiredHPRatio(testRatio)
if spell.GetRequiredHPRatio() != testRatio {
t.Errorf("Expected HP ratio %d, got %d", testRatio, spell.GetRequiredHPRatio())
}
// Test spell copy
spellCopy := spell.Copy()
if spellCopy == nil {
t.Fatal("Spell copy returned nil")
}
if spellCopy.GetSpellID() != spell.GetSpellID() {
t.Error("Spell copy should have same spell ID")
}
if spellCopy.GetTier() != spell.GetTier() {
t.Error("Spell copy should have same tier")
}
}
func TestSkillTypes(t *testing.T) {
// Test Skill creation and methods
testID := int32(10)
testName := "TestSkill"
testCurrent := int16(50)
testMax := int16(100)
skill := NewSkill(testID, testName, testCurrent, testMax)
if skill == nil {
t.Fatal("NewSkill returned nil")
}
if skill.SkillID != testID {
t.Errorf("Expected skill ID %d, got %d", testID, skill.SkillID)
}
if skill.Name != testName {
t.Errorf("Expected skill name '%s', got '%s'", testName, skill.Name)
}
if skill.GetCurrentVal() != testCurrent {
t.Errorf("Expected current value %d, got %d", testCurrent, skill.GetCurrentVal())
}
if skill.MaxVal != testMax {
t.Errorf("Expected max value %d, got %d", testMax, skill.MaxVal)
}
// Test skill value modification
newValue := int16(75)
skill.SetCurrentVal(newValue)
if skill.GetCurrentVal() != newValue {
t.Errorf("Expected current value %d after setting, got %d", newValue, skill.GetCurrentVal())
}
// Test skill increase
originalValue := skill.GetCurrentVal()
increased := skill.IncreaseSkill()
if increased && skill.GetCurrentVal() <= originalValue {
t.Error("Skill value should increase when IncreaseSkill returns true")
}
// Test skill at max
skill.SetCurrentVal(testMax)
increased = skill.IncreaseSkill()
if increased {
t.Error("Skill at max should not increase")
}
}
func TestMovementLocation(t *testing.T) {
testX, testY, testZ := float32(1.5), float32(2.5), float32(3.5)
testGridID := int32(99)
loc := NewMovementLocation(testX, testY, testZ, testGridID)
if loc == nil {
t.Fatal("NewMovementLocation returned nil")
}
if loc.X != testX || loc.Y != testY || loc.Z != testZ {
t.Errorf("Location coordinates mismatch: expected (%f,%f,%f), got (%f,%f,%f)",
testX, testY, testZ, loc.X, loc.Y, loc.Z)
}
if loc.GridID != testGridID {
t.Errorf("Expected grid ID %d, got %d", testGridID, loc.GridID)
}
// Test copy
locCopy := loc.Copy()
if locCopy == nil {
t.Fatal("Movement location copy returned nil")
}
if locCopy.X != loc.X || locCopy.Y != loc.Y || locCopy.Z != loc.Z {
t.Error("Movement location copy should have same coordinates")
}
if locCopy.GridID != loc.GridID {
t.Error("Movement location copy should have same grid ID")
}
}
func TestTimer(t *testing.T) {
timer := NewTimer()
if timer == nil {
t.Fatal("NewTimer returned nil")
}
// Test initial state
if timer.Enabled() {
t.Error("New timer should not be enabled")
}
if timer.Check() {
t.Error("Disabled timer should not be checked as expired")
}
// Test starting timer
timer.Start(100, false) // 100ms
if !timer.Enabled() {
t.Error("Timer should be enabled after starting")
}
// Test disabling timer
timer.Disable()
if timer.Enabled() {
t.Error("Timer should be disabled after calling Disable")
}
}
func TestSkillBonus(t *testing.T) {
spellID := int32(123)
bonus := NewSkillBonus(spellID)
if bonus == nil {
t.Fatal("NewSkillBonus returned nil")
}
if bonus.SpellID != spellID {
t.Errorf("Expected spell ID %d, got %d", spellID, bonus.SpellID)
}
// Test adding skills
skillID1 := int32(10)
value1 := float32(15.5)
bonus.AddSkill(skillID1, value1)
skillID2 := int32(20)
value2 := float32(25.0)
bonus.AddSkill(skillID2, value2)
// Test getting skills
skills := bonus.GetSkills()
if len(skills) != 2 {
t.Errorf("Expected 2 skills, got %d", len(skills))
}
if skills[skillID1].Value != value1 {
t.Errorf("Expected skill 1 value %f, got %f", value1, skills[skillID1].Value)
}
if skills[skillID2].Value != value2 {
t.Errorf("Expected skill 2 value %f, got %f", value2, skills[skillID2].Value)
}
// Test removing skill
if !bonus.RemoveSkill(skillID1) {
t.Error("Should be able to remove existing skill")
}
updatedSkills := bonus.GetSkills()
if len(updatedSkills) != 1 {
t.Errorf("Expected 1 skill after removal, got %d", len(updatedSkills))
}
// Test removing non-existent skill
if bonus.RemoveSkill(999) {
t.Error("Should not be able to remove non-existent skill")
}
}
// Benchmark tests
func BenchmarkNewNPC(b *testing.B) {
for i := 0; i < b.N; i++ {
NewNPC()
}
}
func BenchmarkNPCPropertyAccess(b *testing.B) {
npc := NewNPC()
npc.SetNPCID(12345)
npc.SetAIStrategy(AIStrategyOffensive)
b.ResetTimer()
for i := 0; i < b.N; i++ {
npc.GetNPCID()
npc.GetAIStrategy()
npc.GetAggroRadius()
}
}
func BenchmarkNPCSpellOperations(b *testing.B) {
npc := NewNPC()
// Create test spells
spells := make([]*NPCSpell, 10)
for i := 0; i < 10; i++ {
spell := NewNPCSpell()
spell.SetSpellID(int32(i + 100))
spell.SetTier(int8(i%5 + 1))
spells[i] = spell
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
npc.SetSpells(spells)
npc.GetSpells()
npc.HasSpells()
}
}
func BenchmarkSkillOperations(b *testing.B) {
skill := NewSkill(1, "TestSkill", 50, 100)
b.ResetTimer()
for i := 0; i < b.N; i++ {
skill.GetCurrentVal()
skill.SetCurrentVal(int16(i % 100))
skill.IncreaseSkill()
}
}

View File

@ -1,502 +0,0 @@
package race_types
import (
"context"
"fmt"
"path/filepath"
"testing"
"zombiezen.com/go/sqlite"
"zombiezen.com/go/sqlite/sqlitex"
)
func TestSQLiteDatabase(t *testing.T) {
// Create temporary database
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_race_types.db")
// Create database pool
pool, err := sqlitex.NewPool(dbPath, sqlitex.PoolOptions{
PoolSize: 1,
})
if err != nil {
t.Fatalf("Failed to create database pool: %v", err)
}
defer pool.Close()
db := NewSQLiteDatabase(pool)
// Test table creation
err = db.CreateRaceTypesTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
// Verify table exists
conn, err := pool.Take(context.Background())
if err != nil {
t.Fatalf("Failed to get connection: %v", err)
}
defer pool.Put(conn)
var tableExists bool
err = sqlitex.ExecuteTransient(conn, "SELECT name FROM sqlite_master WHERE type='table' AND name='race_types'", &sqlitex.ExecOptions{
ResultFunc: func(stmt *sqlite.Stmt) error {
tableExists = true
return nil
},
})
if err != nil {
t.Fatalf("Failed to check table existence: %v", err)
}
if !tableExists {
t.Error("race_types table should exist")
}
}
func TestSQLiteDatabaseOperations(t *testing.T) {
// Create temporary database
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_race_types_ops.db")
// Create database pool
pool, err := sqlitex.NewPool(dbPath, sqlitex.PoolOptions{
PoolSize: 1,
})
if err != nil {
t.Fatalf("Failed to create database pool: %v", err)
}
defer pool.Close()
db := NewSQLiteDatabase(pool)
// Create table
err = db.CreateRaceTypesTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
// Test saving race type
raceType := &RaceType{
RaceTypeID: Sentient,
Category: CategorySentient,
Subcategory: "Human",
ModelName: "Human Male",
}
err = db.SaveRaceType(100, raceType)
if err != nil {
t.Fatalf("Failed to save race type: %v", err)
}
// Test loading race types
masterList := NewMasterRaceTypeList()
err = db.LoadRaceTypes(masterList)
if err != nil {
t.Fatalf("Failed to load race types: %v", err)
}
if masterList.Count() != 1 {
t.Errorf("Expected 1 race type, got %d", masterList.Count())
}
retrievedRaceType := masterList.GetRaceType(100)
if retrievedRaceType != Sentient {
t.Errorf("Expected race type %d, got %d", Sentient, retrievedRaceType)
}
retrievedInfo := masterList.GetRaceTypeByModelID(100)
if retrievedInfo == nil {
t.Fatal("Should retrieve race type info")
}
if retrievedInfo.Category != CategorySentient {
t.Errorf("Expected category %s, got %s", CategorySentient, retrievedInfo.Category)
}
if retrievedInfo.Subcategory != "Human" {
t.Errorf("Expected subcategory 'Human', got %s", retrievedInfo.Subcategory)
}
if retrievedInfo.ModelName != "Human Male" {
t.Errorf("Expected model name 'Human Male', got %s", retrievedInfo.ModelName)
}
// Test updating (replace)
updatedRaceType := &RaceType{
RaceTypeID: Sentient,
Category: CategorySentient,
Subcategory: "Human",
ModelName: "Human Female",
}
err = db.SaveRaceType(100, updatedRaceType)
if err != nil {
t.Fatalf("Failed to update race type: %v", err)
}
// Reload and verify update
masterList = NewMasterRaceTypeList()
err = db.LoadRaceTypes(masterList)
if err != nil {
t.Fatalf("Failed to load race types after update: %v", err)
}
updatedInfo := masterList.GetRaceTypeByModelID(100)
if updatedInfo.ModelName != "Human Female" {
t.Errorf("Expected updated model name 'Human Female', got %s", updatedInfo.ModelName)
}
// Test deletion
err = db.DeleteRaceType(100)
if err != nil {
t.Fatalf("Failed to delete race type: %v", err)
}
// Verify deletion
masterList = NewMasterRaceTypeList()
err = db.LoadRaceTypes(masterList)
if err != nil {
t.Fatalf("Failed to load race types after deletion: %v", err)
}
if masterList.Count() != 0 {
t.Errorf("Expected 0 race types after deletion, got %d", masterList.Count())
}
// Test deletion of non-existent
err = db.DeleteRaceType(999)
if err == nil {
t.Error("Should fail to delete non-existent race type")
}
}
func TestSQLiteDatabaseMultipleRaceTypes(t *testing.T) {
// Create temporary database
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_race_types_multi.db")
// Create database pool
pool, err := sqlitex.NewPool(dbPath, sqlitex.PoolOptions{
PoolSize: 1,
})
if err != nil {
t.Fatalf("Failed to create database pool: %v", err)
}
defer pool.Close()
db := NewSQLiteDatabase(pool)
// Create table
err = db.CreateRaceTypesTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
// Test data
testData := []struct {
modelID int16
raceTypeID int16
category string
subcategory string
modelName string
}{
{100, Sentient, CategorySentient, "Human", "Human Male"},
{101, Sentient, CategorySentient, "Human", "Human Female"},
{200, Undead, CategoryUndead, "Skeleton", "Skeleton Warrior"},
{201, Undead, CategoryUndead, "Zombie", "Zombie Shambler"},
{300, Natural, CategoryNatural, "Wolf", "Dire Wolf"},
{301, Natural, CategoryNatural, "Bear", "Grizzly Bear"},
}
// Save all test data
for _, data := range testData {
raceType := &RaceType{
RaceTypeID: data.raceTypeID,
Category: data.category,
Subcategory: data.subcategory,
ModelName: data.modelName,
}
err = db.SaveRaceType(data.modelID, raceType)
if err != nil {
t.Fatalf("Failed to save race type %d: %v", data.modelID, err)
}
}
// Load and verify all data
masterList := NewMasterRaceTypeList()
err = db.LoadRaceTypes(masterList)
if err != nil {
t.Fatalf("Failed to load race types: %v", err)
}
if masterList.Count() != len(testData) {
t.Errorf("Expected %d race types, got %d", len(testData), masterList.Count())
}
// Verify each race type
for _, data := range testData {
retrievedRaceType := masterList.GetRaceType(data.modelID)
if retrievedRaceType != data.raceTypeID {
t.Errorf("Model %d: expected race type %d, got %d", data.modelID, data.raceTypeID, retrievedRaceType)
}
retrievedInfo := masterList.GetRaceTypeByModelID(data.modelID)
if retrievedInfo == nil {
t.Errorf("Model %d: should have race type info", data.modelID)
continue
}
if retrievedInfo.Category != data.category {
t.Errorf("Model %d: expected category %s, got %s", data.modelID, data.category, retrievedInfo.Category)
}
if retrievedInfo.Subcategory != data.subcategory {
t.Errorf("Model %d: expected subcategory %s, got %s", data.modelID, data.subcategory, retrievedInfo.Subcategory)
}
if retrievedInfo.ModelName != data.modelName {
t.Errorf("Model %d: expected model name %s, got %s", data.modelID, data.modelName, retrievedInfo.ModelName)
}
}
// Test category-based queries by verifying the loaded data
sentientTypes := masterList.GetRaceTypesByCategory(CategorySentient)
if len(sentientTypes) != 2 {
t.Errorf("Expected 2 sentient types, got %d", len(sentientTypes))
}
undeadTypes := masterList.GetRaceTypesByCategory(CategoryUndead)
if len(undeadTypes) != 2 {
t.Errorf("Expected 2 undead types, got %d", len(undeadTypes))
}
naturalTypes := masterList.GetRaceTypesByCategory(CategoryNatural)
if len(naturalTypes) != 2 {
t.Errorf("Expected 2 natural types, got %d", len(naturalTypes))
}
}
func TestSQLiteDatabaseInvalidRaceType(t *testing.T) {
// Create temporary database
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_race_types_invalid.db")
// Create database pool
pool, err := sqlitex.NewPool(dbPath, sqlitex.PoolOptions{
PoolSize: 1,
})
if err != nil {
t.Fatalf("Failed to create database pool: %v", err)
}
defer pool.Close()
db := NewSQLiteDatabase(pool)
// Create table
err = db.CreateRaceTypesTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
// Test saving nil race type
err = db.SaveRaceType(100, nil)
if err == nil {
t.Error("Should fail to save nil race type")
}
// Test saving invalid race type
invalidRaceType := &RaceType{
RaceTypeID: 0, // Invalid
Category: "",
Subcategory: "",
ModelName: "",
}
err = db.SaveRaceType(100, invalidRaceType)
if err == nil {
t.Error("Should fail to save invalid race type")
}
}
func TestSQLiteDatabaseIndexes(t *testing.T) {
// Create temporary database
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_race_types_indexes.db")
// Create database pool
pool, err := sqlitex.NewPool(dbPath, sqlitex.PoolOptions{
PoolSize: 1,
})
if err != nil {
t.Fatalf("Failed to create database pool: %v", err)
}
defer pool.Close()
db := NewSQLiteDatabase(pool)
// Create table
err = db.CreateRaceTypesTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
// Verify indexes exist
conn, err := pool.Take(context.Background())
if err != nil {
t.Fatalf("Failed to get connection: %v", err)
}
defer pool.Put(conn)
indexes := []string{
"idx_race_types_race_id",
"idx_race_types_category",
}
for _, indexName := range indexes {
var indexExists bool
query := "SELECT name FROM sqlite_master WHERE type='index' AND name=?"
err = sqlitex.ExecuteTransient(conn, query, &sqlitex.ExecOptions{
Args: []any{indexName},
ResultFunc: func(stmt *sqlite.Stmt) error {
indexExists = true
return nil
},
})
if err != nil {
t.Fatalf("Failed to check index %s: %v", indexName, err)
}
if !indexExists {
t.Errorf("Index %s should exist", indexName)
}
}
}
func TestSQLiteDatabaseConcurrency(t *testing.T) {
// Create temporary database
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_race_types_concurrent.db")
// Create database pool with multiple connections
pool, err := sqlitex.NewPool(dbPath, sqlitex.PoolOptions{
PoolSize: 3,
})
if err != nil {
t.Fatalf("Failed to create database pool: %v", err)
}
defer pool.Close()
db := NewSQLiteDatabase(pool)
// Create table
err = db.CreateRaceTypesTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
// Test concurrent operations
const numOperations = 10
results := make(chan error, numOperations)
// Concurrent saves
for i := 0; i < numOperations; i++ {
go func(id int) {
raceType := &RaceType{
RaceTypeID: int16(id%5 + 1),
Category: CategorySentient,
Subcategory: "Test",
ModelName: fmt.Sprintf("Test Model %d", id),
}
results <- db.SaveRaceType(int16(100+id), raceType)
}(i)
}
// Wait for all operations to complete
for i := 0; i < numOperations; i++ {
if err := <-results; err != nil {
t.Errorf("Concurrent save operation failed: %v", err)
}
}
// Verify all data was saved
masterList := NewMasterRaceTypeList()
err = db.LoadRaceTypes(masterList)
if err != nil {
t.Fatalf("Failed to load race types after concurrent operations: %v", err)
}
if masterList.Count() != numOperations {
t.Errorf("Expected %d race types after concurrent operations, got %d", numOperations, masterList.Count())
}
}
// Benchmark tests for SQLite database
func BenchmarkSQLiteDatabaseSave(b *testing.B) {
// Create temporary database
tempDir := b.TempDir()
dbPath := filepath.Join(tempDir, "bench_race_types_save.db")
// Create database pool
pool, err := sqlitex.NewPool(dbPath, sqlitex.PoolOptions{
PoolSize: 1,
})
if err != nil {
b.Fatalf("Failed to create database pool: %v", err)
}
defer pool.Close()
db := NewSQLiteDatabase(pool)
db.CreateRaceTypesTable()
raceType := &RaceType{
RaceTypeID: Sentient,
Category: CategorySentient,
Subcategory: "Human",
ModelName: "Human Male",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
db.SaveRaceType(int16(i), raceType)
}
}
func BenchmarkSQLiteDatabaseLoad(b *testing.B) {
// Create temporary database with test data
tempDir := b.TempDir()
dbPath := filepath.Join(tempDir, "bench_race_types_load.db")
// Create database pool
pool, err := sqlitex.NewPool(dbPath, sqlitex.PoolOptions{
PoolSize: 1,
})
if err != nil {
b.Fatalf("Failed to create database pool: %v", err)
}
defer pool.Close()
db := NewSQLiteDatabase(pool)
db.CreateRaceTypesTable()
// Add test data
raceType := &RaceType{
RaceTypeID: Sentient,
Category: CategorySentient,
Subcategory: "Human",
ModelName: "Human Male",
}
for i := 0; i < 1000; i++ {
db.SaveRaceType(int16(i), raceType)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
masterList := NewMasterRaceTypeList()
db.LoadRaceTypes(masterList)
}
}

View File

@ -1,550 +0,0 @@
package race_types
import (
"fmt"
"testing"
)
// Mock implementations for testing
// MockDatabase implements the Database interface for testing
type MockDatabase struct {
raceTypes map[int16]*RaceType
created bool
}
func NewMockDatabase() *MockDatabase {
return &MockDatabase{
raceTypes: make(map[int16]*RaceType),
created: false,
}
}
func (md *MockDatabase) LoadRaceTypes(masterList *MasterRaceTypeList) error {
for modelType, raceType := range md.raceTypes {
masterList.AddRaceType(modelType, raceType.RaceTypeID, raceType.Category, raceType.Subcategory, raceType.ModelName, false)
}
return nil
}
func (md *MockDatabase) SaveRaceType(modelType int16, raceType *RaceType) error {
if raceType == nil || !raceType.IsValid() {
return fmt.Errorf("invalid race type")
}
md.raceTypes[modelType] = &RaceType{
RaceTypeID: raceType.RaceTypeID,
Category: raceType.Category,
Subcategory: raceType.Subcategory,
ModelName: raceType.ModelName,
}
return nil
}
func (md *MockDatabase) DeleteRaceType(modelType int16) error {
if _, exists := md.raceTypes[modelType]; !exists {
return fmt.Errorf("race type with model_type %d not found", modelType)
}
delete(md.raceTypes, modelType)
return nil
}
func (md *MockDatabase) CreateRaceTypesTable() error {
md.created = true
return nil
}
// MockLogger implements the Logger interface for testing
type MockLogger struct {
logs []string
}
func NewMockLogger() *MockLogger {
return &MockLogger{
logs: make([]string, 0),
}
}
func (ml *MockLogger) LogInfo(message string, args ...any) {
ml.logs = append(ml.logs, fmt.Sprintf("INFO: "+message, args...))
}
func (ml *MockLogger) LogError(message string, args ...any) {
ml.logs = append(ml.logs, fmt.Sprintf("ERROR: "+message, args...))
}
func (ml *MockLogger) LogDebug(message string, args ...any) {
ml.logs = append(ml.logs, fmt.Sprintf("DEBUG: "+message, args...))
}
func (ml *MockLogger) LogWarning(message string, args ...any) {
ml.logs = append(ml.logs, fmt.Sprintf("WARNING: "+message, args...))
}
func (ml *MockLogger) GetLogs() []string {
return ml.logs
}
func (ml *MockLogger) Clear() {
ml.logs = ml.logs[:0]
}
// Mock entity for testing race type aware interface
type MockEntity struct {
modelType int16
}
func NewMockEntity(modelType int16) *MockEntity {
return &MockEntity{modelType: modelType}
}
func (me *MockEntity) GetModelType() int16 {
return me.modelType
}
func (me *MockEntity) SetModelType(modelType int16) {
me.modelType = modelType
}
// Test functions
func TestRaceTypeBasics(t *testing.T) {
rt := &RaceType{
RaceTypeID: Sentient,
Category: CategorySentient,
Subcategory: "Human",
ModelName: "Human Male",
}
if !rt.IsValid() {
t.Error("Race type should be valid")
}
if rt.RaceTypeID != Sentient {
t.Errorf("Expected race type ID %d, got %d", Sentient, rt.RaceTypeID)
}
if rt.Category != CategorySentient {
t.Errorf("Expected category %s, got %s", CategorySentient, rt.Category)
}
}
func TestRaceTypeInvalid(t *testing.T) {
rt := &RaceType{
RaceTypeID: 0, // Invalid
Category: "",
Subcategory: "",
ModelName: "",
}
if rt.IsValid() {
t.Error("Race type with zero ID should not be valid")
}
}
func TestMasterRaceTypeList(t *testing.T) {
masterList := NewMasterRaceTypeList()
// Test initial state
if masterList.Count() != 0 {
t.Errorf("Expected count 0, got %d", masterList.Count())
}
// Add a race type
modelID := int16(100)
raceTypeID := int16(Sentient)
category := CategorySentient
subcategory := "Human"
modelName := "Human Male"
if !masterList.AddRaceType(modelID, raceTypeID, category, subcategory, modelName, false) {
t.Error("Failed to add race type")
}
if masterList.Count() != 1 {
t.Errorf("Expected count 1, got %d", masterList.Count())
}
// Test retrieval
retrievedRaceType := masterList.GetRaceType(modelID)
if retrievedRaceType != raceTypeID {
t.Errorf("Expected race type %d, got %d", raceTypeID, retrievedRaceType)
}
// Test category retrieval
retrievedCategory := masterList.GetRaceTypeCategory(modelID)
if retrievedCategory != category {
t.Errorf("Expected category %s, got %s", category, retrievedCategory)
}
// Test duplicate addition
if masterList.AddRaceType(modelID, raceTypeID, category, subcategory, modelName, false) {
t.Error("Should not allow duplicate race type without override")
}
// Test override
newModelName := "Human Female"
if !masterList.AddRaceType(modelID, raceTypeID, category, subcategory, newModelName, true) {
t.Error("Should allow override of existing race type")
}
retrievedInfo := masterList.GetRaceTypeByModelID(modelID)
if retrievedInfo == nil {
t.Fatal("Should retrieve race type info")
}
if retrievedInfo.ModelName != newModelName {
t.Errorf("Expected model name %s, got %s", newModelName, retrievedInfo.ModelName)
}
}
func TestMasterRaceTypeListBaseFunctions(t *testing.T) {
masterList := NewMasterRaceTypeList()
// Add some test race types
testData := []struct {
modelID int16
raceTypeID int16
category string
subcategory string
modelName string
}{
{100, Sentient, CategorySentient, "Human", "Human Male"},
{101, Undead, CategoryUndead, "Skeleton", "Skeleton Warrior"},
{102, Natural, CategoryNatural, "Wolf", "Dire Wolf"},
{103, Dragonkind, CategoryDragonkind, "Dragon", "Red Dragon"},
}
for _, data := range testData {
masterList.AddRaceType(data.modelID, data.raceTypeID, data.category, data.subcategory, data.modelName, false)
}
// Test base type functions
if masterList.GetRaceBaseType(100) != Sentient {
t.Error("Human should be sentient")
}
if masterList.GetRaceBaseType(101) != Undead {
t.Error("Skeleton should be undead")
}
if masterList.GetRaceBaseType(102) != Natural {
t.Error("Wolf should be natural")
}
if masterList.GetRaceBaseType(103) != Dragonkind {
t.Error("Dragon should be dragonkind")
}
// Test category functions
sentientTypes := masterList.GetRaceTypesByCategory(CategorySentient)
if len(sentientTypes) != 1 {
t.Errorf("Expected 1 sentient type, got %d", len(sentientTypes))
}
undeadTypes := masterList.GetRaceTypesByCategory(CategoryUndead)
if len(undeadTypes) != 1 {
t.Errorf("Expected 1 undead type, got %d", len(undeadTypes))
}
// Test subcategory functions
humanTypes := masterList.GetRaceTypesBySubcategory("Human")
if len(humanTypes) != 1 {
t.Errorf("Expected 1 human type, got %d", len(humanTypes))
}
// Test statistics
stats := masterList.GetStatistics()
if stats.TotalRaceTypes != 4 {
t.Errorf("Expected 4 total race types, got %d", stats.TotalRaceTypes)
}
}
func TestMockDatabase(t *testing.T) {
database := NewMockDatabase()
masterList := NewMasterRaceTypeList()
// Test table creation
err := database.CreateRaceTypesTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
if !database.created {
t.Error("Database should be marked as created")
}
// Test saving
raceType := &RaceType{
RaceTypeID: Sentient,
Category: CategorySentient,
Subcategory: "Human",
ModelName: "Human Male",
}
err = database.SaveRaceType(100, raceType)
if err != nil {
t.Fatalf("Failed to save race type: %v", err)
}
// Test loading
err = database.LoadRaceTypes(masterList)
if err != nil {
t.Fatalf("Failed to load race types: %v", err)
}
if masterList.Count() != 1 {
t.Errorf("Expected 1 race type loaded, got %d", masterList.Count())
}
// Test deletion
err = database.DeleteRaceType(100)
if err != nil {
t.Fatalf("Failed to delete race type: %v", err)
}
// Test deletion of non-existent
err = database.DeleteRaceType(999)
if err == nil {
t.Error("Should fail to delete non-existent race type")
}
}
func TestManager(t *testing.T) {
database := NewMockDatabase()
logger := NewMockLogger()
manager := NewManager(database, logger)
// Test initialization
err := manager.Initialize()
if err != nil {
t.Fatalf("Failed to initialize manager: %v", err)
}
if !database.created {
t.Error("Database table should be created during initialization")
}
// Test adding race type
err = manager.AddRaceType(100, Sentient, CategorySentient, "Human", "Human Male")
if err != nil {
t.Fatalf("Failed to add race type: %v", err)
}
// Test retrieval
raceTypeID := manager.GetRaceType(100)
if raceTypeID != Sentient {
t.Errorf("Expected race type %d, got %d", Sentient, raceTypeID)
}
info := manager.GetRaceTypeInfo(100)
if info == nil {
t.Fatal("Should retrieve race type info")
}
if info.ModelName != "Human Male" {
t.Errorf("Expected model name 'Human Male', got %s", info.ModelName)
}
// Test updating
err = manager.UpdateRaceType(100, Sentient, CategorySentient, "Human", "Human Female")
if err != nil {
t.Fatalf("Failed to update race type: %v", err)
}
updatedInfo := manager.GetRaceTypeInfo(100)
if updatedInfo.ModelName != "Human Female" {
t.Errorf("Expected updated model name 'Human Female', got %s", updatedInfo.ModelName)
}
// Test type checking functions
if !manager.IsSentient(100) {
t.Error("Model 100 should be sentient")
}
if manager.IsUndead(100) {
t.Error("Model 100 should not be undead")
}
// Test removal
err = manager.RemoveRaceType(100)
if err != nil {
t.Fatalf("Failed to remove race type: %v", err)
}
// Test removal of non-existent
err = manager.RemoveRaceType(999)
if err == nil {
t.Error("Should fail to remove non-existent race type")
}
}
func TestNPCRaceTypeAdapter(t *testing.T) {
database := NewMockDatabase()
logger := NewMockLogger()
manager := NewManager(database, logger)
// Initialize and add test data
manager.Initialize()
manager.AddRaceType(100, Sentient, CategorySentient, "Human", "Human Male")
manager.AddRaceType(101, Undead, CategoryUndead, "Skeleton", "Skeleton Warrior")
// Create mock entity
entity := NewMockEntity(100)
adapter := NewNPCRaceTypeAdapter(entity, manager)
// Test race type functions
if adapter.GetRaceType() != Sentient {
t.Errorf("Expected race type %d, got %d", Sentient, adapter.GetRaceType())
}
if adapter.GetRaceBaseType() != Sentient {
t.Errorf("Expected base type %d, got %d", Sentient, adapter.GetRaceBaseType())
}
if adapter.GetRaceTypeCategory() != CategorySentient {
t.Errorf("Expected category %s, got %s", CategorySentient, adapter.GetRaceTypeCategory())
}
// Test type checking
if !adapter.IsSentient() {
t.Error("Human should be sentient")
}
if adapter.IsUndead() {
t.Error("Human should not be undead")
}
// Test with undead entity
entity.SetModelType(101)
if !adapter.IsUndead() {
t.Error("Skeleton should be undead")
}
if adapter.IsSentient() {
t.Error("Skeleton should not be sentient")
}
}
func TestRaceTypeConstants(t *testing.T) {
// Test that constants are defined correctly
if Sentient == 0 {
t.Error("Sentient should not be 0")
}
if Natural == 0 {
t.Error("Natural should not be 0")
}
if Undead == 0 {
t.Error("Undead should not be 0")
}
// Test category constants
if CategorySentient == "" {
t.Error("CategorySentient should not be empty")
}
if CategoryNatural == "" {
t.Error("CategoryNatural should not be empty")
}
if CategoryUndead == "" {
t.Error("CategoryUndead should not be empty")
}
}
func TestManagerCommands(t *testing.T) {
database := NewMockDatabase()
logger := NewMockLogger()
manager := NewManager(database, logger)
// Initialize and add test data
manager.Initialize()
manager.AddRaceType(100, Sentient, CategorySentient, "Human", "Human Male")
manager.AddRaceType(101, Undead, CategoryUndead, "Skeleton", "Skeleton Warrior")
// Test stats command
result := manager.ProcessCommand([]string{"stats"})
if result == "" {
t.Error("Stats command should return non-empty result")
}
// Test list command
result = manager.ProcessCommand([]string{"list", CategorySentient})
if result == "" {
t.Error("List command should return non-empty result")
}
// Test info command
result = manager.ProcessCommand([]string{"info", "100"})
if result == "" {
t.Error("Info command should return non-empty result")
}
// Test category command
result = manager.ProcessCommand([]string{"category"})
if result == "" {
t.Error("Category command should return non-empty result")
}
// Test invalid command
result = manager.ProcessCommand([]string{"invalid"})
if result == "" {
t.Error("Invalid command should return error message")
}
}
// Benchmark tests
func BenchmarkMasterRaceTypeListLookup(b *testing.B) {
masterList := NewMasterRaceTypeList()
// Add many race types
for i := 0; i < 1000; i++ {
masterList.AddRaceType(int16(i), int16(i%10+1), CategorySentient, "Test", fmt.Sprintf("Model_%d", i), false)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
masterList.GetRaceType(int16(i % 1000))
}
}
func BenchmarkManagerOperations(b *testing.B) {
database := NewMockDatabase()
logger := NewMockLogger()
manager := NewManager(database, logger)
manager.Initialize()
// Add some test data
for i := 0; i < 100; i++ {
manager.AddRaceType(int16(i), int16(i%10+1), CategorySentient, "Test", fmt.Sprintf("Model_%d", i))
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
manager.GetRaceType(int16(i % 100))
}
}
func BenchmarkNPCRaceTypeAdapter(b *testing.B) {
database := NewMockDatabase()
logger := NewMockLogger()
manager := NewManager(database, logger)
manager.Initialize()
// Add test data
for i := 0; i < 50; i++ {
manager.AddRaceType(int16(i), int16(i%10+1), CategorySentient, "Test", fmt.Sprintf("Model_%d", i))
}
entity := NewMockEntity(25)
adapter := NewNPCRaceTypeAdapter(entity, manager)
b.ResetTimer()
for i := 0; i < b.N; i++ {
entity.SetModelType(int16(i % 50))
adapter.GetRaceType()
adapter.IsSentient()
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,370 +0,0 @@
# Player System
The player system (`internal/player`) provides comprehensive player character management for the EQ2Go server emulator. This system is converted from the original C++ EQ2EMu Player implementation with modern Go concurrency patterns and clean architecture principles.
## Overview
The player system manages all aspects of player characters including:
- **Character Data**: Basic character information, stats, appearance
- **Experience**: Adventure and tradeskill XP, vitality, debt recovery
- **Skills**: Skill progression, bonuses, and management
- **Spells**: Spell book, casting, maintained effects, passive spells
- **Combat**: Auto-attack, combat state, weapon handling
- **Quests**: Quest tracking, progress, completion, rewards
- **Social**: Friends, ignore lists, guild membership
- **Economy**: Currency management, trading, banking
- **Movement**: Position tracking, spawn visibility, zone transfers
- **Character Flags**: Various character state flags and preferences
- **History**: Character history tracking and events
- **Items**: Inventory, equipment, appearance items
- **Housing**: House ownership, vault access
## Architecture
### Core Components
**Player** - Main player character struct extending Entity
**PlayerInfo** - Detailed character information for serialization
**PlayerManager** - Multi-player management and coordination
**PlayerControlFlags** - Character flag state management
**CharacterInstances** - Instance lockout tracking
### Key Files
- `player.go` - Core Player struct and basic functionality
- `player_info.go` - PlayerInfo struct for character sheet data
- `character_flags.go` - Character flag management
- `currency.go` - Coin and currency handling
- `experience.go` - XP, leveling, and vitality systems
- `combat.go` - Combat mechanics and processing
- `quest_management.go` - Quest system integration
- `spell_management.go` - Spell book and casting management
- `skill_management.go` - Skill system integration
- `spawn_management.go` - Spawn visibility and tracking
- `manager.go` - Multi-player management system
- `interfaces.go` - System integration interfaces
- `constants.go` - All character flag and system constants
- `types.go` - Data structures and type definitions
## Player Creation
```go
// Create a new player
player := player.NewPlayer()
// Set basic information
player.SetCharacterID(12345)
player.SetName("TestPlayer")
player.SetLevel(10)
player.SetClass(1) // Fighter
player.SetRace(0) // Human
// Initialize player info
info := player.GetPlayerInfo()
info.SetBindZone(1)
info.SetHouseZone(0)
```
## Character Flags
The system supports all EverQuest II character flags:
```go
// Set character flags
player.SetCharacterFlag(player.CF_ANONYMOUS)
player.SetCharacterFlag(player.CF_LFG)
player.ResetCharacterFlag(player.CF_AFK)
// Check flags
if player.GetCharacterFlag(player.CF_ROLEPLAYING) {
// Handle roleplaying state
}
```
### Available Flags
- **CF_COMBAT_EXPERIENCE_ENABLED** - Adventure XP enabled
- **CF_QUEST_EXPERIENCE_ENABLED** - Tradeskill XP enabled
- **CF_ANONYMOUS** - Anonymous mode
- **CF_ROLEPLAYING** - Roleplaying flag
- **CF_AFK** - Away from keyboard
- **CF_LFG** - Looking for group
- **CF_LFW** - Looking for work
- **CF_HIDE_HOOD/CF_HIDE_HELM** - Hide equipment
- **CF_ALLOW_DUEL_INVITES** - Allow duel invites
- **CF_ALLOW_TRADE_INVITES** - Allow trade invites
- **CF_ALLOW_GROUP_INVITES** - Allow group invites
- And many more...
## Experience System
```go
// Add adventure XP
if player.AddXP(1000) {
// Player may have leveled up
if player.GetLevel() > oldLevel {
// Handle level up
}
}
// Add tradeskill XP
player.AddTSXP(500)
// Check XP status
if player.AdventureXPEnabled() {
currentXP := player.GetXP()
neededXP := player.GetNeededXP()
}
// Calculate XP from kills
victim := &entity.Spawn{...}
xpReward := player.CalculateXP(victim)
```
## Currency Management
```go
// Add coins
player.AddCoins(10000) // Add 1 gold
// Remove coins (with validation)
if player.RemoveCoins(5000) {
// Transaction successful
}
// Check coin amounts
copper := player.GetCoinsCopper()
silver := player.GetCoinsSilver()
gold := player.GetCoinsGold()
plat := player.GetCoinsPlat()
// Validate sufficient funds
if player.HasCoins(1000) {
// Player can afford the cost
}
```
## Combat System
```go
// Enter combat
player.InCombat(true, false) // Melee combat
// Enter ranged combat
player.InCombat(true, true) // Ranged combat
// Exit combat
player.StopCombat(0) // Stop all combat
// Process combat (called periodically)
player.ProcessCombat()
// Check combat state
if player.GetInfoStruct().GetEngageCommands() != 0 {
// Player is in combat
}
```
## Spell Management
```go
// Add spell to spell book
player.AddSpellBookEntry(spellID, tier, slot, spellType, timer, true)
// Check if player has spell
if player.HasSpell(spellID, tier, false, false) {
// Player has the spell
}
// Get spell tier
tier := player.GetSpellTier(spellID)
// Lock/unlock spells
player.LockAllSpells()
player.UnlockAllSpells(true, nil)
// Manage passive spells
player.AddPassiveSpell(spellID, tier)
player.ApplyPassiveSpells()
```
## Quest Integration
```go
// Get active quest
quest := player.GetQuest(questID)
// Check quest completion
if player.HasQuestBeenCompleted(questID) {
count := player.GetQuestCompletedCount(questID)
}
// Update quest progress
player.SetStepComplete(questID, stepID)
player.AddStepProgress(questID, stepID, progress)
// Check quest requirements
if player.CanReceiveQuest(questID, nil) {
// Player can accept quest
}
```
## Social Features
```go
// Manage friends
player.AddFriend("FriendName", true)
if player.IsFriend("SomeName") {
// Handle friend interaction
}
// Manage ignore list
player.AddIgnore("PlayerName", true)
if player.IsIgnored("SomeName") {
// Player is ignored
}
// Get social lists
friends := player.GetFriends()
ignored := player.GetIgnoredPlayers()
```
## Spawn Management
```go
// Check spawn visibility
if player.ShouldSendSpawn(spawn) {
// Send spawn to player
player.SetSpawnSentState(spawn, player.SPAWN_STATE_SENDING)
}
// Get spawn by player index
spawn := player.GetSpawnByIndex(index)
// Remove spawn from player view
player.RemoveSpawn(spawn, false)
// Process spawn updates
player.CheckSpawnStateQueue()
```
## Player Manager
```go
// Create manager
config := player.ManagerConfig{
MaxPlayers: 1000,
SaveInterval: 5 * time.Minute,
StatsInterval: 1 * time.Minute,
EnableValidation: true,
EnableEvents: true,
EnableStatistics: true,
}
manager := player.NewManager(config)
// Start manager
manager.Start()
// Add player
err := manager.AddPlayer(player)
// Get players
allPlayers := manager.GetAllPlayers()
zonePlayers := manager.GetPlayersInZone(zoneID)
player := manager.GetPlayerByName("PlayerName")
// Send messages
manager.SendToAll(message)
manager.SendToZone(zoneID, message)
// Get statistics
stats := manager.GetPlayerStats()
```
## Database Integration
The system provides interfaces for database operations:
```go
type PlayerDatabase interface {
LoadPlayer(characterID int32) (*Player, error)
SavePlayer(player *Player) error
DeletePlayer(characterID int32) error
LoadPlayerQuests(characterID int32) ([]*quests.Quest, error)
SavePlayerQuests(characterID int32, quests []*quests.Quest) error
// ... more methods
}
```
## Event System
```go
type PlayerEventHandler interface {
OnPlayerLogin(player *Player) error
OnPlayerLogout(player *Player) error
OnPlayerDeath(player *Player, killer *entity.Entity) error
OnPlayerLevelUp(player *Player, newLevel int8) error
// ... more events
}
// Register event handler
manager.AddEventHandler(myHandler)
```
## Thread Safety
All player operations are thread-safe using appropriate synchronization:
- **RWMutex** for read-heavy operations (spawn maps, quest lists)
- **Atomic operations** for simple flags and counters
- **Channel-based communication** for background processing
- **Proper lock ordering** to prevent deadlocks
## Integration with Other Systems
The player system integrates with:
- **Entity System** - Players extend entities for combat capabilities
- **Spell System** - Complete spell casting and effect management
- **Quest System** - Quest tracking and progression
- **Skill System** - Skill advancement and bonuses
- **Faction System** - Reputation and standings
- **Title System** - Character titles and achievements
- **Trade System** - Player-to-player trading
- **Housing System** - House ownership and access
## Performance Considerations
- **Efficient spawn tracking** with hash maps for O(1) lookups
- **Periodic processing** batched for better performance
- **Memory-efficient data structures** with proper cleanup
- **Background save operations** to avoid blocking gameplay
- **Statistics collection** with minimal overhead
## Migration from C++
This Go implementation maintains compatibility with the original C++ EQ2EMu player system while providing:
- **Modern concurrency** with goroutines and channels
- **Better error handling** with Go's error interface
- **Cleaner architecture** with interface-based design
- **Improved maintainability** with package organization
- **Enhanced testing** capabilities
## TODO Items
The conversion includes TODO comments marking areas for future implementation:
- **LUA integration** for scripting support
- **Advanced packet handling** for client communication
- **Complete database schema** implementation
- **Full item system** integration
- **Group and raid** management
- **PvP mechanics** and flagging
- **Mail system** implementation
- **Appearance system** completion
## Usage Examples
See the individual file documentation and method comments for detailed usage examples. The system is designed to be used alongside the existing EQ2Go server infrastructure with proper initialization and configuration.

View File

@ -1,584 +0,0 @@
package player
import (
"fmt"
"testing"
"time"
"eq2emu/internal/quests"
)
// TestNewPlayer tests player creation
func TestNewPlayer(t *testing.T) {
p := NewPlayer()
if p == nil {
t.Fatal("NewPlayer returned nil")
}
// Verify default values
if p.GetCharacterID() != 0 {
t.Errorf("Expected character ID 0, got %d", p.GetCharacterID())
}
if p.GetTutorialStep() != 0 {
t.Errorf("Expected tutorial step 0, got %d", p.GetTutorialStep())
}
if !p.IsPlayer() {
t.Error("Expected IsPlayer to return true")
}
// Check that maps are initialized
if p.playerQuests == nil {
t.Error("playerQuests map not initialized")
}
if p.completedQuests == nil {
t.Error("completedQuests map not initialized")
}
if p.friendList == nil {
t.Error("friendList map not initialized")
}
}
// TestPlayerManager tests the player manager functionality
func TestPlayerManager(t *testing.T) {
config := ManagerConfig{
MaxPlayers: 100,
SaveInterval: time.Minute * 5,
StatsInterval: time.Second * 30,
}
manager := NewManager(config)
if manager == nil {
t.Fatal("NewManager returned nil")
}
// Test adding a player
player := NewPlayer()
player.SetSpawnID(1001) // Set unique spawn ID
player.SetCharacterID(123)
player.SetName("TestPlayer")
player.SetLevel(10)
err := manager.AddPlayer(player)
if err != nil {
t.Fatalf("Failed to add player: %v", err)
}
// Test retrieving player by ID
retrieved := manager.GetPlayer(player.GetSpawnID())
if retrieved == nil {
t.Error("Failed to retrieve player by ID")
}
// Test retrieving player by name
byName := manager.GetPlayerByName("TestPlayer")
if byName == nil {
// Debug: Check what name was actually stored
allPlayers := manager.GetAllPlayers()
if len(allPlayers) > 0 {
t.Errorf("Failed to retrieve player by name. Player has name: %s", allPlayers[0].GetName())
} else {
t.Error("Failed to retrieve player by name. No players in manager")
}
}
// Test retrieving player by character ID
byCharID := manager.GetPlayerByCharacterID(123)
if byCharID == nil {
t.Error("Failed to retrieve player by character ID")
}
// Test player count
count := manager.GetPlayerCount()
if count != 1 {
t.Errorf("Expected player count 1, got %d", count)
}
// Test removing player
err = manager.RemovePlayer(player.GetSpawnID())
if err != nil {
t.Fatalf("Failed to remove player: %v", err)
}
count = manager.GetPlayerCount()
if count != 0 {
t.Errorf("Expected player count 0 after removal, got %d", count)
}
}
// TestPlayerDatabase tests database operations
func TestPlayerDatabase(t *testing.T) {
t.Skip("Skipping test - requires MySQL database connection and proper PlayerDatabase implementation")
// TODO: Implement TestPlayerDatabase with MySQL connection
// This test needs to be rewritten to use the new database wrapper
// and MySQL instead of SQLite
// TODO: Re-implement with MySQL database
// Test player save/load/delete operations
}
// TestPlayerCombat tests combat-related functionality
func TestPlayerCombat(t *testing.T) {
player := NewPlayer()
player.SetCharacterID(1)
player.SetLevel(10)
player.SetHP(100)
player.SetTotalHP(100)
// Test combat state
player.InCombat(true, false)
if player.GetPlayerEngageCommands() == 0 {
t.Error("Expected player to be in combat")
}
player.InCombat(false, false)
if player.GetPlayerEngageCommands() != 0 {
t.Error("Expected player to not be in combat")
}
// Test death state
player.SetHP(0)
if !player.IsDead() {
t.Error("Expected player to be dead with 0 HP")
}
player.SetHP(50)
if player.IsDead() {
t.Error("Expected player to be alive with 50 HP")
}
// Test mentorship
player.SetMentorStats(5, 0, true)
player.EnableResetMentorship()
if !player.ResetMentorship() {
t.Error("Expected mentorship reset to succeed")
}
}
// TestPlayerExperience tests experience system
func TestPlayerExperience(t *testing.T) {
player := NewPlayer()
player.SetCharacterID(1)
player.SetLevel(1)
// Set initial XP
player.SetXP(0)
player.SetNeededXP(1000)
// Test XP gain
leveledUp := player.AddXP(500)
if leveledUp {
t.Error("Should not level up with 500/1000 XP")
}
currentXP := player.GetXP()
if currentXP != 500 {
t.Errorf("Expected XP 500, got %d", currentXP)
}
// Test level up
leveledUp = player.AddXP(600) // Total: 1100, should level up
if !leveledUp {
t.Error("Should level up with 1100/1000 XP")
}
newLevel := player.GetLevel()
if newLevel != 2 {
t.Errorf("Expected level 2 after level up, got %d", newLevel)
}
// Test tradeskill XP
player.SetTSXP(0)
player.SetNeededTSXP(500)
leveledUp = player.AddTSXP(600)
if !leveledUp {
t.Error("Should level up tradeskill with 600/500 XP")
}
}
// TestPlayerQuests tests quest management
func TestPlayerQuests(t *testing.T) {
player := NewPlayer()
player.SetCharacterID(1)
// Create test quest
quest := &quests.Quest{
ID: 100,
Name: "Test Quest",
}
// Test adding quest
// Note: AddQuest method doesn't exist yet - would need implementation
// player.AddQuest(quest)
player.playerQuests[100] = quest
retrieved := player.GetQuest(100)
if retrieved == nil {
t.Error("Failed to retrieve added quest")
}
allQuests := player.GetPlayerQuests()
if len(allQuests) != 1 {
t.Errorf("Expected 1 quest, got %d", len(allQuests))
}
// Test completing quest
player.RemoveQuest(100, true)
retrieved = player.GetQuest(100)
if retrieved != nil {
t.Error("Quest should be removed after completion")
}
completed := player.GetCompletedQuest(100)
if completed == nil {
t.Error("Quest should be in completed list")
}
}
// TestPlayerSkills tests skill management
func TestPlayerSkills(t *testing.T) {
player := NewPlayer()
player.SetCharacterID(1)
// Test adding skill
player.AddSkill(1, 10, 100, true)
// Test skill retrieval by name
skill := player.GetSkillByName("TestSkill", false)
// Note: This will return nil since we're using stubs
_ = skill
// Test removing skill
player.RemovePlayerSkill(1, false)
}
// TestPlayerFlags tests character flag management
func TestPlayerFlags(t *testing.T) {
player := NewPlayer()
player.SetCharacterID(1)
// Test setting flags
player.SetCharacterFlag(CF_ANONYMOUS)
if !player.GetCharacterFlag(CF_ANONYMOUS) {
t.Error("Expected anonymous flag to be set")
}
// Test resetting flags
player.ResetCharacterFlag(CF_ANONYMOUS)
if player.GetCharacterFlag(CF_ANONYMOUS) {
t.Error("Expected anonymous flag to be reset")
}
// Test toggle
player.ToggleCharacterFlag(CF_ANONYMOUS)
if !player.GetCharacterFlag(CF_ANONYMOUS) {
t.Error("Expected anonymous flag to be toggled on")
}
player.ToggleCharacterFlag(CF_ANONYMOUS)
if player.GetCharacterFlag(CF_ANONYMOUS) {
t.Error("Expected anonymous flag to be toggled off")
}
}
// TestPlayerFriends tests friend list management
func TestPlayerFriends(t *testing.T) {
player := NewPlayer()
player.SetCharacterID(1)
// Test adding friend
player.AddFriend("BestFriend", false)
if !player.IsFriend("BestFriend") {
t.Error("Expected BestFriend to be in friend list")
}
friends := player.GetFriends()
if len(friends) != 1 {
t.Errorf("Expected 1 friend, got %d", len(friends))
}
// Test removing friend
player.RemoveFriend("BestFriend")
if player.IsFriend("BestFriend") {
t.Error("Expected BestFriend to be removed from friend list")
}
}
// TestPlayerIgnore tests ignore list management
func TestPlayerIgnore(t *testing.T) {
player := NewPlayer()
player.SetCharacterID(1)
// Test adding to ignore list
player.AddIgnore("Annoying", false)
if !player.IsIgnored("Annoying") {
t.Error("Expected Annoying to be in ignore list")
}
ignored := player.GetIgnoredPlayers()
if len(ignored) != 1 {
t.Errorf("Expected 1 ignored player, got %d", len(ignored))
}
// Test removing from ignore list
player.RemoveIgnore("Annoying")
if player.IsIgnored("Annoying") {
t.Error("Expected Annoying to be removed from ignore list")
}
}
// TestPlayerMovement tests movement-related methods
func TestPlayerMovement(t *testing.T) {
player := NewPlayer()
player.SetCharacterID(1)
// Test position
player.SetX(100.5)
player.SetY(200.5, false)
player.SetZ(300.5)
if player.GetX() != 100.5 {
t.Errorf("Expected X 100.5, got %f", player.GetX())
}
// Test heading
player.SetHeadingFromFloat(180.0)
heading := player.GetHeading()
_ = heading // Heading conversion is complex, just ensure it doesn't panic
// Test distance calculation
distance := player.GetDistance(150.5, 250.5, 350.5, true)
if distance <= 0 {
t.Error("Expected positive distance")
}
// Test movement speeds
player.SetSideSpeed(5.0, false)
if player.GetSideSpeed() != 5.0 {
t.Errorf("Expected side speed 5.0, got %f", player.GetSideSpeed())
}
player.SetVertSpeed(3.0, false)
if player.GetVertSpeed() != 3.0 {
t.Errorf("Expected vert speed 3.0, got %f", player.GetVertSpeed())
}
}
// TestPlayerCurrency tests currency management
func TestPlayerCurrency(t *testing.T) {
player := NewPlayer()
player.SetCharacterID(1)
// Test adding coins
player.AddCoins(1000)
if !player.HasCoins(1000) {
t.Error("Expected player to have 1000 coins")
}
// Test removing coins
success := player.RemoveCoins(500)
if !success {
t.Error("Expected to successfully remove 500 coins")
}
if !player.HasCoins(500) {
t.Error("Expected player to have 500 coins remaining")
}
// Test insufficient coins
success = player.RemoveCoins(1000)
if success {
t.Error("Should not be able to remove 1000 coins when only 500 available")
}
}
// TestPlayerSpells tests spell management
func TestPlayerSpells(t *testing.T) {
player := NewPlayer()
player.SetCharacterID(1)
// Test spell book
player.AddSpellBookEntry(100, 1, 1, 0, 0, true)
hasSpell := player.HasSpell(100, 1, false, false)
if !hasSpell {
t.Error("Expected player to have spell 100")
}
// Test removing spell
player.RemoveSpellBookEntry(100, false)
hasSpell = player.HasSpell(100, 1, false, false)
if hasSpell {
t.Error("Expected spell to be removed")
}
// Test passive spells
player.ApplyPassiveSpells()
player.RemoveAllPassives()
}
// TestPlayerInfo tests PlayerInfo functionality
func TestPlayerInfo(t *testing.T) {
player := NewPlayer()
player.SetCharacterID(1)
info := NewPlayerInfo(player)
if info == nil {
t.Fatal("NewPlayerInfo returned nil")
}
// Test bind point
info.SetBindZone(100)
info.SetBindX(50.0)
info.SetBindY(60.0)
info.SetBindZ(70.0)
info.SetBindHeading(90.0)
// Test house zone
info.SetHouseZone(200)
// Test account age
info.SetAccountAge(365)
// Test XP calculations
player.SetXP(500)
player.SetNeededXP(1000)
info.CalculateXPPercentages()
// Test TS XP calculations
player.SetTSXP(250)
player.SetNeededTSXP(500)
info.CalculateTSXPPercentages()
}
// TestPlayerEquipment tests equipment and appearance
func TestPlayerEquipment(t *testing.T) {
player := NewPlayer()
player.SetCharacterID(1)
player.SetHP(100) // Set HP so player is not dead
player.SetTotalHP(100)
// Test equipment allowance check
canEquip := player.IsAllowedCombatEquip(0, false)
if !canEquip {
t.Error("Expected to be able to change equipment out of combat")
}
// Test in combat equipment change
player.InCombat(true, false)
canEquip = player.IsAllowedCombatEquip(0, false)
if canEquip {
t.Error("Should not be able to change primary weapon in combat")
}
}
// TestPlayerCleanup tests cleanup methods
func TestPlayerCleanup(t *testing.T) {
player := NewPlayer()
player.SetCharacterID(1)
// Add some data
player.AddFriend("Friend1", false)
player.AddIgnore("Ignored1", false)
quest := &quests.Quest{ID: 1, Name: "Quest1"}
// player.AddQuest(quest) - method doesn't exist yet
player.playerQuests[1] = quest
// Test cleanup
player.ClearEverything()
// Verify data is cleared
friends := player.GetFriends()
if len(friends) != 0 {
t.Error("Expected friends list to be cleared")
}
ignored := player.GetIgnoredPlayers()
if len(ignored) != 0 {
t.Error("Expected ignore list to be cleared")
}
}
// BenchmarkPlayerCreation benchmarks player creation
func BenchmarkPlayerCreation(b *testing.B) {
for i := 0; i < b.N; i++ {
p := NewPlayer()
p.SetCharacterID(int32(i))
p.SetName(fmt.Sprintf("Player%d", i))
}
}
// BenchmarkManagerOperations benchmarks manager operations
func BenchmarkManagerOperations(b *testing.B) {
config := ManagerConfig{
MaxPlayers: 1000,
}
manager := NewManager(config)
// Create players
players := make([]*Player, 100)
for i := 0; i < 100; i++ {
players[i] = NewPlayer()
players[i].SetCharacterID(int32(i))
players[i].SetName(fmt.Sprintf("Player%d", i))
players[i].SetSpawnID(int32(2000 + i)) // Unique spawn IDs
manager.AddPlayer(players[i])
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Benchmark lookups
_ = manager.GetPlayer(int32(i % 100))
_ = manager.GetPlayerByCharacterID(int32(i % 100))
}
}
// TestConcurrentAccess tests thread safety
func TestConcurrentAccess(t *testing.T) {
config := ManagerConfig{
MaxPlayers: 100,
}
manager := NewManager(config)
// Start manager
err := manager.Start()
if err != nil {
t.Fatalf("Failed to start manager: %v", err)
}
defer manager.Stop()
// Concurrent player additions
done := make(chan bool, 10)
for i := 0; i < 10; i++ {
go func(id int) {
player := NewPlayer()
player.SetCharacterID(int32(id))
player.SetName(fmt.Sprintf("Player%d", id))
player.SetSpawnID(int32(1000 + id)) // Unique spawn IDs
manager.AddPlayer(player)
done <- true
}(i)
}
// Wait for all goroutines
for i := 0; i < 10; i++ {
<-done
}
// Verify all players added
count := manager.GetPlayerCount()
if count != 10 {
t.Errorf("Expected 10 players, got %d", count)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +0,0 @@
# Character Races System
The character races system manages the 21 playable races in EverQuest II that can be used by both player characters and NPCs.
## Overview
This package handles character race definitions, including:
- Race IDs and names for all 21 playable races
- Alignment classifications (good, evil, neutral)
- Race-based stat modifiers and bonuses
- Starting location information
- Race compatibility and restrictions
## Why Not Under /internal/player?
While these are "player races" in the sense that they're the races players can choose at character creation, this package is used by the Entity system which is inherited by both:
- **Players**: For character creation and race-based mechanics
- **NPCs**: For humanoid NPCs that use player race models (e.g., Human_NPC, Dwarf_NPC, etc.)
The Entity system (`internal/entity`) references races for stat calculations, spell bonuses, and other race-based mechanics that apply to both players and NPCs.
## Relationship to Race Types
This package is distinct from the NPC race types system (`internal/npc/race_types`):
- **Character Races** (this package): The 21 playable races (Human, Elf, Dwarf, etc.)
- **Race Types** (`internal/npc/race_types`): Creature classifications (Dragon, Undead, Animal, etc.)
An NPC can have both:
- A character race (if it's a humanoid using a player race model)
- A race type (its creature classification for AI and combat mechanics)

View File

@ -1,557 +0,0 @@
# Recipe System
Complete tradeskill recipe management system for EverQuest II, converted from C++ EQ2EMu codebase. Handles recipe definitions, player recipe knowledge, crafting components, and database persistence with modern Go concurrency patterns.
## Overview
The recipe system manages all aspects of tradeskill crafting in EQ2:
- **Recipe Management**: Master recipe lists with complex component relationships
- **Player Recipe Knowledge**: Individual player recipe collections and progress tracking
- **Recipe Books**: Tradeskill recipe book items and learning mechanics
- **Database Integration**: Complete SQLite persistence with efficient loading
- **Component System**: Multi-slot component requirements (primary, fuel, build slots)
- **Crafting Stages**: 5-stage crafting progression with products and byproducts
- **Tradeskill Classes**: Class-based recipe access control and validation
- **Integration Interfaces**: Seamless integration with player, item, client, and event systems
## Core Components
### Recipe Data Structure
```go
type Recipe struct {
// Core recipe identification
ID int32 // Unique recipe ID
SoeID int32 // SOE recipe CRC ID
Name string // Recipe display name
Description string // Recipe description
// Requirements and properties
Level int8 // Required level
Tier int8 // Recipe tier (1-10)
Skill int32 // Required tradeskill ID
Technique int32 // Technique requirement
Knowledge int32 // Knowledge requirement
Classes int32 // Tradeskill class bitmask
// Product information
ProductItemID int32 // Resulting item ID
ProductName string // Product display name
ProductQty int8 // Quantity produced
// Component titles and quantities (6 slots)
PrimaryBuildCompTitle string // Primary component name
Build1CompTitle string // Build slot 1 name
Build2CompTitle string // Build slot 2 name
Build3CompTitle string // Build slot 3 name
Build4CompTitle string // Build slot 4 name
FuelCompTitle string // Fuel component name
// Components map: slot -> list of valid item IDs
// Slots: 0=primary, 1-4=build slots, 5=fuel
Components map[int8][]int32
// Products map: stage -> products/byproducts (5 stages)
Products map[int8]*RecipeProducts
// Player progression
HighestStage int8 // Highest completed stage for player recipes
}
```
### Component Slots
The recipe system uses 6 component slots:
- **Slot 0 (Primary)**: Primary crafting material
- **Slot 1-4 (Build)**: Secondary build components
- **Slot 5 (Fuel)**: Fuel component for crafting device
Each slot can contain multiple valid item IDs, providing flexibility in component selection.
### Crafting Stages
Recipes support 5 crafting stages (0-4), each with potential products and byproducts:
```go
type RecipeProducts struct {
ProductID int32 // Main product item ID
ProductQty int8 // Product quantity
ByproductID int32 // Byproduct item ID
ByproductQty int8 // Byproduct quantity
}
```
### Master Recipe Lists
```go
type MasterRecipeList struct {
recipes map[int32]*Recipe // Recipe ID -> Recipe
mutex sync.RWMutex // Thread-safe access
}
type MasterRecipeBookList struct {
recipeBooks map[int32]*Recipe // Book ID -> Recipe
mutex sync.RWMutex // Thread-safe access
}
```
### Player Recipe Collections
```go
type PlayerRecipeList struct {
recipes map[int32]*Recipe // Player's known recipes
mutex sync.RWMutex // Thread-safe access
}
type PlayerRecipeBookList struct {
recipeBooks map[int32]*Recipe // Player's recipe books
mutex sync.RWMutex // Thread-safe access
}
```
## Database Integration
### RecipeManager
High-level recipe system management with complete database integration:
```go
type RecipeManager struct {
db *database.DB
masterRecipeList *MasterRecipeList
masterRecipeBookList *MasterRecipeBookList
loadedRecipes map[int32]*Recipe
loadedRecipeBooks map[int32]*Recipe
statisticsEnabled bool
stats RecipeManagerStats
}
```
### Key Operations
#### Loading Recipes
```go
// Load all recipes from database with complex component relationships
manager.LoadRecipes()
// Load all recipe books
manager.LoadRecipeBooks()
// Load player-specific recipes
manager.LoadPlayerRecipes(playerRecipeList, characterID)
// Load player recipe books
manager.LoadPlayerRecipeBooks(playerRecipeBookList, characterID)
```
#### Saving Recipe Progress
```go
// Save new recipe for player
manager.SavePlayerRecipe(characterID, recipeID)
// Save recipe book for player
manager.SavePlayerRecipeBook(characterID, recipebookID)
// Update recipe progress
manager.UpdatePlayerRecipe(characterID, recipeID, highestStage)
```
### Database Schema
The system integrates with these key database tables:
- `recipe`: Master recipe definitions
- `recipe_comp_list`: Component list definitions
- `recipe_comp_list_item`: Individual component items
- `recipe_secondary_comp`: Secondary component mappings
- `character_recipes`: Player recipe knowledge
- `character_recipe_books`: Player recipe book ownership
- `items`: Item definitions and recipe books
## Integration Interfaces
### RecipeSystemAdapter
Primary integration interface for external systems:
```go
type RecipeSystemAdapter interface {
// Player Integration
GetPlayerRecipeList(characterID int32) *PlayerRecipeList
LoadPlayerRecipes(characterID int32) error
// Recipe Management
GetRecipe(recipeID int32) *Recipe
ValidateRecipe(recipe *Recipe) bool
// Progress Tracking
UpdateRecipeProgress(characterID int32, recipeID int32, stage int8) error
GetRecipeProgress(characterID int32, recipeID int32) int8
}
```
### Database Adapter
Abstracts database operations for testing and multiple database support:
```go
type DatabaseRecipeAdapter interface {
LoadAllRecipes() ([]*Recipe, error)
LoadPlayerRecipes(characterID int32) ([]*Recipe, error)
SavePlayerRecipe(characterID int32, recipeID int32) error
UpdatePlayerRecipe(characterID int32, recipeID int32, highestStage int8) error
}
```
### Player Integration
```go
type PlayerRecipeAdapter interface {
CanPlayerLearnRecipe(characterID int32, recipeID int32) bool
GetPlayerTradeskillLevel(characterID int32, skillID int32) int32
AwardTradeskillExperience(characterID int32, skillID int32, experience int32) error
}
```
### Item Integration
```go
type ItemRecipeAdapter interface {
ValidateRecipeComponents(recipeID int32) bool
PlayerHasComponents(characterID int32, recipeID int32) bool
ConsumeRecipeComponents(characterID int32, recipeID int32) error
AwardRecipeProduct(characterID int32, itemID int32, quantity int8) error
}
```
### Client Integration
```go
type ClientRecipeAdapter interface {
SendRecipeList(characterID int32) error
SendRecipeLearned(characterID int32, recipeID int32) error
SendTradeskillWindow(characterID int32, deviceID int32) error
SendCraftingResults(characterID int32, success bool, itemID int32, quantity int8) error
}
```
### Event Integration
```go
type EventRecipeAdapter interface {
OnRecipeLearned(characterID int32, recipeID int32) error
OnRecipeCrafted(characterID int32, recipeID int32, success bool) error
CheckCraftingAchievements(characterID int32, recipeID int32) error
}
```
## Usage Examples
### Basic Recipe Management
```go
// Create recipe manager
db, _ := database.Open("world.db")
manager := NewRecipeManager(db)
// Load all recipes and recipe books
manager.LoadRecipes()
manager.LoadRecipeBooks()
// Get a specific recipe
recipe := manager.GetRecipe(12345)
if recipe != nil {
fmt.Printf("Recipe: %s (Level %d, Tier %d)\n",
recipe.Name, recipe.Level, recipe.Tier)
}
// Check recipe validation
if recipe.IsValid() {
fmt.Println("Recipe is valid")
}
```
### Player Recipe Operations
```go
// Load player recipes
playerRecipes := NewPlayerRecipeList()
manager.LoadPlayerRecipes(playerRecipes, characterID)
// Check if player knows a recipe
if playerRecipes.HasRecipe(recipeID) {
fmt.Println("Player knows this recipe")
}
// Learn a new recipe
recipe := manager.GetRecipe(recipeID)
if recipe != nil {
playerCopy := NewRecipeFromRecipe(recipe)
playerRecipes.AddRecipe(playerCopy)
manager.SavePlayerRecipe(characterID, recipeID)
}
```
### Recipe Component Analysis
```go
// Get components for each slot
for slot := int8(0); slot < 6; slot++ {
components := recipe.GetComponentsBySlot(slot)
title := recipe.GetComponentTitleForSlot(slot)
quantity := recipe.GetComponentQuantityForSlot(slot)
fmt.Printf("Slot %d (%s): %d needed\n", slot, title, quantity)
for _, itemID := range components {
fmt.Printf(" - Item ID: %d\n", itemID)
}
}
```
### Crafting Stage Processing
```go
// Process each crafting stage
for stage := int8(0); stage < 5; stage++ {
products := recipe.GetProductsForStage(stage)
if products != nil {
fmt.Printf("Stage %d produces:\n", stage)
fmt.Printf(" Product: %d (qty: %d)\n",
products.ProductID, products.ProductQty)
if products.ByproductID > 0 {
fmt.Printf(" Byproduct: %d (qty: %d)\n",
products.ByproductID, products.ByproductQty)
}
}
}
```
### Integration with Full System
```go
// Create system dependencies
deps := &RecipeSystemDependencies{
Database: &DatabaseRecipeAdapterImpl{},
Player: &PlayerRecipeAdapterImpl{},
Item: &ItemRecipeAdapterImpl{},
Client: &ClientRecipeAdapterImpl{},
Event: &EventRecipeAdapterImpl{},
Crafting: &CraftingRecipeAdapterImpl{},
}
// Create integrated recipe system
adapter := NewRecipeManagerAdapter(db, deps)
adapter.Initialize()
// Handle recipe learning workflow
err := adapter.PlayerLearnRecipe(characterID, recipeID)
if err != nil {
fmt.Printf("Failed to learn recipe: %v\n", err)
}
// Handle recipe book acquisition workflow
err = adapter.PlayerObtainRecipeBook(characterID, bookID)
if err != nil {
fmt.Printf("Failed to obtain recipe book: %v\n", err)
}
```
## Tradeskill Classes
The system supports all EQ2 tradeskill classes with bitmask-based access control:
### Base Classes
- **Provisioner** (food and drink)
- **Woodworker** (wooden items, bows, arrows)
- **Carpenter** (furniture and housing items)
- **Outfitter** (light and medium armor)
- **Armorer** (heavy armor and shields)
- **Weaponsmith** (metal weapons)
- **Tailor** (cloth armor and bags)
- **Scholar** (spells and combat arts)
- **Jeweler** (jewelry)
- **Sage** (advanced spells)
- **Alchemist** (potions and poisons)
- **Craftsman** (general crafting)
- **Tinkerer** (tinkered items)
### Class Validation
```go
// Check if a tradeskill class can use a recipe
canUse := recipe.CanUseRecipeByClass(classID)
// Special handling for "any class" recipes (adornments + artisan)
if recipe.Classes < 4 {
// Any class can use this recipe
}
```
## Crafting Devices
Recipes require specific crafting devices:
- **Stove & Keg**: Provisioning recipes
- **Forge**: Weaponsmithing and armoring
- **Sewing Table & Mannequin**: Tailoring and outfitting
- **Woodworking Table**: Woodworking and carpentry
- **Chemistry Table**: Alchemy
- **Jeweler's Table**: Jewelcrafting
- **Loom & Spinning Wheel**: Advanced tailoring
- **Engraved Desk**: Scribing and sage recipes
- **Work Bench**: Tinkering
## Statistics and Monitoring
### RecipeManagerStats
```go
type RecipeManagerStats struct {
TotalRecipesLoaded int32
TotalRecipeBooksLoaded int32
PlayersWithRecipes int32
LoadOperations int32
SaveOperations int32
}
// Get current statistics
stats := manager.GetStatistics()
fmt.Printf("Loaded %d recipes, %d recipe books\n",
stats.TotalRecipesLoaded, stats.TotalRecipeBooksLoaded)
```
### Validation
```go
// Comprehensive system validation
issues := manager.Validate()
for _, issue := range issues {
fmt.Printf("Validation issue: %s\n", issue)
}
// Recipe-specific validation
if !recipe.IsValid() {
fmt.Println("Recipe has invalid data")
}
```
## Thread Safety
All recipe system components are fully thread-safe:
- **RWMutex Protection**: All maps and collections use read-write mutexes
- **Atomic Operations**: Statistics counters use atomic operations where appropriate
- **Safe Copying**: Recipe copying creates deep copies of all nested data
- **Concurrent Access**: Multiple goroutines can safely read recipe data simultaneously
- **Write Synchronization**: All write operations are properly synchronized
## Performance Considerations
### Efficient Loading
- **Batch Loading**: All recipes loaded in single database query with complex joins
- **Component Resolution**: Components loaded separately and linked to recipes
- **Memory Management**: Recipes cached in memory for fast access
- **Statistics Tracking**: Optional statistics collection for performance monitoring
### Memory Usage
- **Component Maps**: Pre-allocated component arrays for each recipe
- **Product Arrays**: Fixed-size arrays for 5 crafting stages
- **Player Copies**: Player recipe instances are copies, not references
- **Cleanup**: Proper cleanup of database connections and prepared statements
## Error Handling
### Comprehensive Error Types
```go
var (
ErrRecipeNotFound = errors.New("recipe not found")
ErrRecipeBookNotFound = errors.New("recipe book not found")
ErrInvalidRecipeID = errors.New("invalid recipe ID")
ErrDuplicateRecipe = errors.New("duplicate recipe ID")
ErrMissingComponents = errors.New("missing required components")
ErrInsufficientSkill = errors.New("insufficient skill level")
ErrWrongTradeskillClass = errors.New("wrong tradeskill class")
ErrCannotLearnRecipe = errors.New("cannot learn recipe")
ErrCannotUseRecipeBook = errors.New("cannot use recipe book")
)
```
### Error Propagation
All database operations return detailed errors that can be handled appropriately by calling systems.
## Database Schema Compatibility
### C++ EQ2EMu Compatibility
The Go implementation maintains full compatibility with the existing C++ EQ2EMu database schema:
- **Recipe Table**: Direct mapping of all C++ Recipe fields
- **Component System**: Preserves complex component relationships
- **Player Data**: Compatible with existing character recipe storage
- **Query Optimization**: Uses same optimized queries as C++ version
### Migration Support
The system can seamlessly work with existing EQ2EMu databases without requiring schema changes or data migration.
## Future Enhancements
Areas marked for future implementation:
- **Lua Integration**: Recipe validation and custom logic via Lua scripts
- **Advanced Crafting**: Rare material handling and advanced crafting mechanics
- **Recipe Discovery**: Dynamic recipe discovery and experimentation
- **Guild Recipes**: Guild-specific recipe sharing and management
- **Seasonal Recipes**: Time-based recipe availability
- **Recipe Sets**: Collection-based achievements and bonuses
## Testing
### Unit Tests
```go
func TestRecipeValidation(t *testing.T) {
recipe := NewRecipe()
recipe.ID = 12345
recipe.Name = "Test Recipe"
recipe.Level = 50
recipe.Tier = 5
assert.True(t, recipe.IsValid())
}
func TestPlayerRecipeOperations(t *testing.T) {
playerRecipes := NewPlayerRecipeList()
recipe := NewRecipe()
recipe.ID = 12345
assert.True(t, playerRecipes.AddRecipe(recipe))
assert.True(t, playerRecipes.HasRecipe(12345))
assert.Equal(t, 1, playerRecipes.Size())
}
```
### Integration Tests
```go
func TestDatabaseIntegration(t *testing.T) {
db := setupTestDatabase()
manager := NewRecipeManager(db)
err := manager.LoadRecipes()
assert.NoError(t, err)
recipes, books := manager.Size()
assert.Greater(t, recipes, int32(0))
}
```
This recipe system provides a complete, thread-safe, and efficient implementation of EverQuest II tradeskill recipes with modern Go patterns while maintaining compatibility with the existing C++ EQ2EMu architecture.

View File

@ -1,865 +0,0 @@
package recipes
import (
"testing"
)
// Test Recipe creation and basic operations
func TestNewRecipe(t *testing.T) {
recipe := NewRecipe()
if recipe == nil {
t.Error("NewRecipe should not return nil")
}
if recipe.Components == nil {
t.Error("Components map should be initialized")
}
if recipe.Products == nil {
t.Error("Products map should be initialized")
}
}
func TestNewRecipeFromRecipe(t *testing.T) {
// Test with nil source
recipe := NewRecipeFromRecipe(nil)
if recipe == nil {
t.Error("NewRecipeFromRecipe with nil should return valid recipe")
}
// Test with valid source
source := NewRecipe()
source.ID = 12345
source.Name = "Test Recipe"
source.Level = 50
source.Tier = 5
source.Icon = 100
source.Components[0] = []int32{1001, 1002}
source.Products[0] = &RecipeProducts{
ProductID: 2001,
ProductQty: 1,
}
copied := NewRecipeFromRecipe(source)
if copied == nil {
t.Error("NewRecipeFromRecipe should not return nil")
}
if copied.ID != source.ID {
t.Error("Copied recipe should have same ID")
}
if copied.Name != source.Name {
t.Error("Copied recipe should have same name")
}
if copied.Level != source.Level {
t.Error("Copied recipe should have same level")
}
if copied.Tier != source.Tier {
t.Error("Copied recipe should have same tier")
}
// Check component copying
if len(copied.Components[0]) != len(source.Components[0]) {
t.Error("Components should be copied")
}
// Check product copying
if copied.Products[0] == nil {
t.Error("Products should be copied")
}
if copied.Products[0].ProductID != source.Products[0].ProductID {
t.Error("Product data should be copied correctly")
}
}
func TestRecipeIsValid(t *testing.T) {
// Test invalid recipe (empty)
recipe := NewRecipe()
if recipe.IsValid() {
t.Error("Empty recipe should not be valid")
}
// Test valid recipe
recipe.ID = 12345
recipe.Name = "Valid Recipe"
recipe.Level = 50
recipe.Tier = 5 // Need valid tier for validation
if !recipe.IsValid() {
t.Error("Recipe with valid data should be valid")
}
// Test invalid ID
recipe.ID = 0
if recipe.IsValid() {
t.Error("Recipe with ID 0 should not be valid")
}
// Test empty name
recipe.ID = 12345
recipe.Name = ""
if recipe.IsValid() {
t.Error("Recipe with empty name should not be valid")
}
// Test invalid level
recipe.Name = "Valid Recipe"
recipe.Tier = 5
recipe.Level = -1
if recipe.IsValid() {
t.Error("Recipe with negative level should not be valid")
}
recipe.Level = 101
if recipe.IsValid() {
t.Error("Recipe with level > 100 should not be valid")
}
// Test invalid tier
recipe.Level = 50
recipe.Tier = 0
if recipe.IsValid() {
t.Error("Recipe with tier 0 should not be valid")
}
recipe.Tier = 11
if recipe.IsValid() {
t.Error("Recipe with tier > 10 should not be valid")
}
}
func TestRecipeGetTotalBuildComponents(t *testing.T) {
recipe := NewRecipe()
// Test with no build components
if recipe.GetTotalBuildComponents() != 0 {
t.Error("Recipe with no build components should return 0")
}
// Add build components to different slots
recipe.Components[SlotBuild1] = []int32{1001}
recipe.Components[SlotBuild3] = []int32{1003, 1004}
count := recipe.GetTotalBuildComponents()
if count != 2 {
t.Errorf("Expected 2 build component slots, got %d", count)
}
}
func TestRecipeCanUseRecipeByClass(t *testing.T) {
recipe := NewRecipe()
// Test "any class" recipe
recipe.Classes = 2
if !recipe.CanUseRecipeByClass(1) {
t.Error("Any class recipe should be usable by any class")
}
// Test specific class requirement
recipe.Classes = 1 << 3 // Bit 3 set (class ID 3)
if !recipe.CanUseRecipeByClass(3) {
t.Error("Recipe should be usable by matching class")
}
if recipe.CanUseRecipeByClass(2) {
t.Error("Recipe should not be usable by non-matching class")
}
}
func TestRecipeComponentOperations(t *testing.T) {
recipe := NewRecipe()
// Test getting components by slot
recipe.Components[0] = []int32{1001, 1002, 1003}
components := recipe.GetComponentsBySlot(0)
if len(components) != 3 {
t.Error("Should return all components for slot")
}
// Test with empty slot
emptyComponents := recipe.GetComponentsBySlot(1)
if len(emptyComponents) != 0 {
t.Error("Empty slot should return empty slice")
}
}
func TestRecipeProductOperations(t *testing.T) {
recipe := NewRecipe()
// Test getting products for stage
recipe.Products[0] = &RecipeProducts{
ProductID: 2001,
ProductQty: 1,
ByproductID: 2002,
ByproductQty: 2,
}
products := recipe.GetProductsForStage(0)
if products == nil {
t.Error("Should return products for stage")
}
if products.ProductID != 2001 {
t.Error("Product ID should match")
}
if products.ByproductID != 2002 {
t.Error("Byproduct ID should match")
}
// Test with empty stage
emptyProducts := recipe.GetProductsForStage(1)
if emptyProducts != nil {
t.Error("Empty stage should return nil")
}
}
// Test MasterRecipeList operations
func TestNewMasterRecipeList(t *testing.T) {
list := NewMasterRecipeList()
if list == nil {
t.Error("NewMasterRecipeList should not return nil")
}
if list.recipes == nil {
t.Error("Recipes map should be initialized")
}
if list.stats == nil {
t.Error("Statistics should be initialized")
}
}
func TestMasterRecipeListAddRecipe(t *testing.T) {
list := NewMasterRecipeList()
// Test adding nil recipe
if list.AddRecipe(nil) {
t.Error("Adding nil recipe should fail")
}
// Test adding invalid recipe
invalidRecipe := NewRecipe()
if list.AddRecipe(invalidRecipe) {
t.Error("Adding invalid recipe should fail")
}
// Test adding valid recipe
validRecipe := NewRecipe()
validRecipe.ID = 12345
validRecipe.Name = "Test Recipe"
validRecipe.Level = 50
validRecipe.Tier = 5 // Need valid tier
if !list.AddRecipe(validRecipe) {
t.Error("Adding valid recipe should succeed")
}
// Test adding duplicate recipe
duplicate := NewRecipe()
duplicate.ID = 12345
duplicate.Name = "Duplicate"
duplicate.Level = 60
if list.AddRecipe(duplicate) {
t.Error("Adding duplicate recipe ID should fail")
}
}
func TestMasterRecipeListGetRecipe(t *testing.T) {
list := NewMasterRecipeList()
// Test getting non-existent recipe
recipe := list.GetRecipe(99999)
if recipe != nil {
t.Error("Getting non-existent recipe should return nil")
}
// Add a recipe
testRecipe := NewRecipe()
testRecipe.ID = 12345
testRecipe.Name = "Test Recipe"
testRecipe.Level = 50
testRecipe.Tier = 5 // Need valid tier
list.AddRecipe(testRecipe)
// Test getting existing recipe
retrieved := list.GetRecipe(12345)
if retrieved == nil {
t.Error("Getting existing recipe should not return nil")
}
if retrieved.ID != 12345 {
t.Error("Retrieved recipe should have correct ID")
}
}
func TestMasterRecipeListSize(t *testing.T) {
list := NewMasterRecipeList()
// Test empty list
if list.Size() != 0 {
t.Error("Empty list should have size 0")
}
// Add recipes
for i := 1; i <= 3; i++ {
recipe := NewRecipe()
recipe.ID = int32(i)
recipe.Name = "Test Recipe"
recipe.Level = 50
recipe.Tier = 5
list.AddRecipe(recipe)
}
if list.Size() != 3 {
t.Error("List with 3 recipes should have size 3")
}
}
func TestMasterRecipeListClear(t *testing.T) {
list := NewMasterRecipeList()
// Add some recipes
for i := 1; i <= 3; i++ {
recipe := NewRecipe()
recipe.ID = int32(i)
recipe.Name = "Test Recipe"
recipe.Level = 50
recipe.Tier = 5
list.AddRecipe(recipe)
}
// Clear the list
list.ClearRecipes()
if list.Size() != 0 {
t.Error("Cleared list should have size 0")
}
if list.GetRecipe(1) != nil {
t.Error("Recipe should not exist after clear")
}
}
func TestMasterRecipeListGetRecipesByTier(t *testing.T) {
list := NewMasterRecipeList()
// Add recipes with different tiers
for i := 1; i <= 5; i++ {
recipe := NewRecipe()
recipe.ID = int32(i)
recipe.Name = "Test Recipe"
recipe.Level = int8(i * 10)
recipe.Tier = int8(i)
list.AddRecipe(recipe)
}
// Get recipes for tier 3
tier3Recipes := list.GetRecipesByTier(3)
if len(tier3Recipes) != 1 {
t.Errorf("Expected 1 recipe for tier 3, got %d", len(tier3Recipes))
}
if tier3Recipes[0].Tier != 3 {
t.Error("Recipe should have tier 3")
}
// Get recipes for non-existent tier
emptyTier := list.GetRecipesByTier(99)
if len(emptyTier) != 0 {
t.Error("Non-existent tier should return empty slice")
}
}
func TestMasterRecipeListGetRecipesBySkill(t *testing.T) {
list := NewMasterRecipeList()
// Add recipes with different skills
skillRecipe1 := NewRecipe()
skillRecipe1.ID = 1
skillRecipe1.Name = "Skill Recipe 1"
skillRecipe1.Level = 50
skillRecipe1.Tier = 5
skillRecipe1.Skill = 100
list.AddRecipe(skillRecipe1)
skillRecipe2 := NewRecipe()
skillRecipe2.ID = 2
skillRecipe2.Name = "Skill Recipe 2"
skillRecipe2.Level = 60
skillRecipe2.Tier = 6
skillRecipe2.Skill = 100
list.AddRecipe(skillRecipe2)
differentSkillRecipe := NewRecipe()
differentSkillRecipe.ID = 3
differentSkillRecipe.Name = "Different Skill Recipe"
differentSkillRecipe.Level = 50
differentSkillRecipe.Tier = 5
differentSkillRecipe.Skill = 200
list.AddRecipe(differentSkillRecipe)
// Get recipes for skill 100
skill100Recipes := list.GetRecipesBySkill(100)
if len(skill100Recipes) != 2 {
t.Errorf("Expected 2 recipes for skill 100, got %d", len(skill100Recipes))
}
// Get recipes for skill 200
skill200Recipes := list.GetRecipesBySkill(200)
if len(skill200Recipes) != 1 {
t.Errorf("Expected 1 recipe for skill 200, got %d", len(skill200Recipes))
}
}
// Test PlayerRecipeList operations
func TestNewPlayerRecipeList(t *testing.T) {
playerList := NewPlayerRecipeList()
if playerList == nil {
t.Error("NewPlayerRecipeList should not return nil")
}
if playerList.recipes == nil {
t.Error("Recipes map should be initialized")
}
}
func TestPlayerRecipeListAddRecipe(t *testing.T) {
playerList := NewPlayerRecipeList()
// Test adding nil recipe
if playerList.AddRecipe(nil) {
t.Error("Adding nil recipe should fail")
}
// Test adding valid recipe
recipe := NewRecipe()
recipe.ID = 12345
recipe.Name = "Test Recipe"
recipe.Level = 50
recipe.Tier = 5
if !playerList.AddRecipe(recipe) {
t.Error("Adding valid recipe should succeed")
}
// Test adding duplicate recipe
duplicate := NewRecipe()
duplicate.ID = 12345
duplicate.Name = "Duplicate"
duplicate.Level = 60
if playerList.AddRecipe(duplicate) {
t.Error("Adding duplicate recipe should fail")
}
}
func TestPlayerRecipeListGetRecipe(t *testing.T) {
playerList := NewPlayerRecipeList()
// Test checking non-existent recipe
if playerList.GetRecipe(99999) != nil {
t.Error("Player should not have non-existent recipe")
}
// Add a recipe
recipe := NewRecipe()
recipe.ID = 12345
recipe.Name = "Test Recipe"
recipe.Level = 50
recipe.Tier = 5
playerList.AddRecipe(recipe)
// Test checking existing recipe
if playerList.GetRecipe(12345) == nil {
t.Error("Player should have added recipe")
}
}
func TestPlayerRecipeListSize(t *testing.T) {
playerList := NewPlayerRecipeList()
// Test empty list
if playerList.Size() != 0 {
t.Error("Empty player recipe list should have size 0")
}
// Add recipes
for i := 1; i <= 3; i++ {
recipe := NewRecipe()
recipe.ID = int32(i)
recipe.Name = "Test Recipe"
recipe.Level = 50
recipe.Tier = 5
playerList.AddRecipe(recipe)
}
if playerList.Size() != 3 {
t.Error("Player recipe list with 3 recipes should have size 3")
}
}
// Test Recipe Book List operations
func TestNewMasterRecipeBookList(t *testing.T) {
bookList := NewMasterRecipeBookList()
if bookList == nil {
t.Error("NewMasterRecipeBookList should not return nil")
}
}
func TestMasterRecipeBookListOperations(t *testing.T) {
bookList := NewMasterRecipeBookList()
// Test adding recipe book
recipeBook := NewRecipe()
recipeBook.ID = 5001
recipeBook.BookID = 5001 // Need BookID for recipe books
recipeBook.Name = "Test Recipe Book"
recipeBook.Level = 1
recipeBook.Tier = 1
if !bookList.AddRecipeBook(recipeBook) {
t.Error("Adding valid recipe book should succeed")
}
// Test getting recipe book
retrieved := bookList.GetRecipeBook(5001)
if retrieved == nil {
t.Error("Should retrieve added recipe book")
}
if retrieved.ID != 5001 {
t.Error("Retrieved recipe book should have correct ID")
}
// Test size
if bookList.Size() != 1 {
t.Error("Recipe book list should have size 1")
}
}
func TestNewPlayerRecipeBookList(t *testing.T) {
playerBookList := NewPlayerRecipeBookList()
if playerBookList == nil {
t.Error("NewPlayerRecipeBookList should not return nil")
}
}
// Test RecipeManager operations
func TestNewRecipeManager(t *testing.T) {
manager := NewRecipeManager()
if manager == nil {
t.Error("NewRecipeManager should not return nil")
}
if manager.masterRecipeList == nil {
t.Error("Master recipe list should be initialized")
}
if manager.masterRecipeBookList == nil {
t.Error("Master recipe book list should be initialized")
}
if manager.loadedRecipes == nil {
t.Error("Loaded recipes map should be initialized")
}
if manager.loadedRecipeBooks == nil {
t.Error("Loaded recipe books map should be initialized")
}
}
func TestRecipeManagerGetters(t *testing.T) {
manager := NewRecipeManager()
// Test getting master lists
masterList := manager.GetMasterRecipeList()
if masterList == nil {
t.Error("Should return master recipe list")
}
masterBookList := manager.GetMasterRecipeBookList()
if masterBookList == nil {
t.Error("Should return master recipe book list")
}
// Test getting non-existent recipe
recipe := manager.GetRecipe(99999)
if recipe != nil {
t.Error("Non-existent recipe should return nil")
}
// Test getting non-existent recipe book
book := manager.GetRecipeBook(99999)
if book != nil {
t.Error("Non-existent recipe book should return nil")
}
}
func TestRecipeManagerStatistics(t *testing.T) {
manager := NewRecipeManager()
// Test getting statistics when enabled
manager.GetStatistics() // Call method without assignment to avoid lock copy
// Test disabling statistics
manager.SetStatisticsEnabled(false)
manager.GetStatistics() // Call method without assignment to avoid lock copy
// Test enabling statistics
manager.SetStatisticsEnabled(true)
}
func TestRecipeManagerValidation(t *testing.T) {
manager := NewRecipeManager()
// Test validation of empty manager
issues := manager.Validate()
if len(issues) > 0 {
// Manager validation might find issues, that's fine
}
}
func TestRecipeManagerSize(t *testing.T) {
manager := NewRecipeManager()
// Test size of empty manager
recipes, books := manager.Size()
if recipes != 0 {
t.Error("Empty manager should have 0 recipes")
}
if books != 0 {
t.Error("Empty manager should have 0 recipe books")
}
}
// Test RecipeComponent operations
func TestRecipeComponent(t *testing.T) {
component := &RecipeComponent{
ItemID: 1001,
Slot: 0,
}
if component.ItemID != 1001 {
t.Error("Component item ID should be set correctly")
}
if component.Slot != 0 {
t.Error("Component slot should be set correctly")
}
}
// Test RecipeProducts operations
func TestRecipeProducts(t *testing.T) {
products := &RecipeProducts{
ProductID: 2001,
ProductQty: 1,
ByproductID: 2002,
ByproductQty: 2,
}
if products.ProductID != 2001 {
t.Error("Product ID should be set correctly")
}
if products.ProductQty != 1 {
t.Error("Product quantity should be set correctly")
}
if products.ByproductID != 2002 {
t.Error("Byproduct ID should be set correctly")
}
if products.ByproductQty != 2 {
t.Error("Byproduct quantity should be set correctly")
}
}
// Test Statistics operations
func TestNewStatistics(t *testing.T) {
stats := NewStatistics()
if stats == nil {
t.Error("NewStatistics should not return nil")
}
if stats.RecipesByTier == nil {
t.Error("RecipesByTier map should be initialized")
}
if stats.RecipesBySkill == nil {
t.Error("RecipesBySkill map should be initialized")
}
}
func TestStatisticsOperations(t *testing.T) {
stats := NewStatistics()
// Test incrementing lookups
initialLookups := stats.RecipeLookups
stats.IncrementRecipeLookups()
if stats.RecipeLookups != initialLookups+1 {
t.Error("Recipe lookups should be incremented")
}
// Test incrementing recipe book lookups
initialBookLookups := stats.RecipeBookLookups
stats.IncrementRecipeBookLookups()
if stats.RecipeBookLookups != initialBookLookups+1 {
t.Error("Recipe book lookups should be incremented")
}
// Test incrementing player recipe loads
initialLoads := stats.PlayerRecipeLoads
stats.IncrementPlayerRecipeLoads()
if stats.PlayerRecipeLoads != initialLoads+1 {
t.Error("Player recipe loads should be incremented")
}
// Test incrementing component queries
initialQueries := stats.ComponentQueries
stats.IncrementComponentQueries()
if stats.ComponentQueries != initialQueries+1 {
t.Error("Component queries should be incremented")
}
// Test getting snapshot
snapshot := stats.GetSnapshot()
if snapshot.RecipeLookups != stats.RecipeLookups {
t.Error("Snapshot should match current stats")
}
}
// Test error constants
func TestErrorConstants(t *testing.T) {
if ErrRecipeNotFound == nil {
t.Error("ErrRecipeNotFound should be defined")
}
if ErrRecipeBookNotFound == nil {
t.Error("ErrRecipeBookNotFound should be defined")
}
if ErrInvalidRecipeID == nil {
t.Error("ErrInvalidRecipeID should be defined")
}
if ErrDuplicateRecipe == nil {
t.Error("ErrDuplicateRecipe should be defined")
}
}
// Test constants
func TestConstants(t *testing.T) {
// Test slot constants
if SlotPrimary != 0 {
t.Error("SlotPrimary should be 0")
}
if SlotBuild1 != 1 {
t.Error("SlotBuild1 should be 1")
}
if SlotFuel != 5 {
t.Error("SlotFuel should be 5")
}
// Test stage constants
if Stage0 != 0 {
t.Error("Stage0 should be 0")
}
if Stage4 != 4 {
t.Error("Stage4 should be 4")
}
// Test validation constants
if MinRecipeID != 1 {
t.Error("MinRecipeID should be 1")
}
if MaxRecipeLevel != 100 {
t.Error("MaxRecipeLevel should be 100")
}
}
// Edge case tests
func TestRecipeEdgeCases(t *testing.T) {
recipe := NewRecipe()
// Test accessing non-existent component slots
components := recipe.GetComponentsBySlot(99)
if len(components) != 0 {
t.Error("Non-existent slot should return empty slice")
}
// Test accessing non-existent product stages
products := recipe.GetProductsForStage(99)
if products != nil {
t.Error("Non-existent stage should return nil")
}
// Test recipe with maximum values
recipe.ID = MaxRecipeID
recipe.Level = MaxRecipeLevel
recipe.Tier = MaxTier
recipe.Name = "Max Recipe"
if !recipe.IsValid() {
t.Error("Recipe with maximum valid values should be valid")
}
}
func TestMasterRecipeListEdgeCases(t *testing.T) {
list := NewMasterRecipeList()
// Test operations on empty list
emptyTier := list.GetRecipesByTier(1)
if len(emptyTier) != 0 {
t.Error("Empty list should return empty slice for tier query")
}
emptySkill := list.GetRecipesBySkill(100)
if len(emptySkill) != 0 {
t.Error("Empty list should return empty slice for skill query")
}
// Test clearing already empty list
list.ClearRecipes()
if list.Size() != 0 {
t.Error("Clearing empty list should keep size 0")
}
}
// Benchmark tests
func BenchmarkNewRecipe(b *testing.B) {
for i := 0; i < b.N; i++ {
NewRecipe()
}
}
func BenchmarkRecipeCopy(b *testing.B) {
source := NewRecipe()
source.ID = 12345
source.Name = "Benchmark Recipe"
source.Level = 50
source.Tier = 5
source.Components[0] = []int32{1001, 1002, 1003}
b.ResetTimer()
for i := 0; i < b.N; i++ {
NewRecipeFromRecipe(source)
}
}
func BenchmarkMasterRecipeListAdd(b *testing.B) {
list := NewMasterRecipeList()
b.ResetTimer()
for i := 0; i < b.N; i++ {
recipe := NewRecipe()
recipe.ID = int32(i)
recipe.Name = "Benchmark Recipe"
recipe.Level = 50
recipe.Tier = 5
list.AddRecipe(recipe)
}
}
func BenchmarkMasterRecipeListGet(b *testing.B) {
list := NewMasterRecipeList()
// Add test recipes
for i := 1; i <= 1000; i++ {
recipe := NewRecipe()
recipe.ID = int32(i)
recipe.Name = "Benchmark Recipe"
recipe.Level = 50
recipe.Tier = 5
list.AddRecipe(recipe)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
list.GetRecipe(int32((i % 1000) + 1))
}
}
func BenchmarkPlayerRecipeListOperations(b *testing.B) {
playerList := NewPlayerRecipeList()
b.ResetTimer()
for i := 0; i < b.N; i++ {
recipe := NewRecipe()
recipe.ID = int32(i)
recipe.Name = "Benchmark Recipe"
recipe.Level = 50
recipe.Tier = 5
playerList.AddRecipe(recipe)
playerList.GetRecipe(int32(i))
}
}

View File

@ -1,138 +0,0 @@
# Rules System
The rules system provides configurable game parameters and settings for the EQ2 server. It has been fully converted from the original C++ EQ2EMu implementation to Go.
## Overview
The rules system consists of:
- **Rule Categories**: Major groupings of rules (Player, Combat, World, etc.)
- **Rule Types**: Specific rule types within each category
- **Rule Sets**: Collections of rules that can be switched between
- **Global/Zone Rules**: Global rules apply server-wide, zone rules override for specific zones
## Core Components
### Files
- `constants.go` - Rule categories, types, and constants
- `types.go` - Core data structures (Rule, RuleSet, RuleManagerStats)
- `manager.go` - Main RuleManager implementation with default rules
- `database.go` - Database operations for rule persistence
- `interfaces.go` - Integration interfaces and adapters
- `rules_test.go` - Comprehensive test suite
### Main Types
- `Rule` - Individual rule with category, type, value, and type conversion methods
- `RuleSet` - Collection of rules with ID and name
- `RuleManager` - Central management of all rules and rule sets
- `DatabaseService` - Database operations for rule persistence
## Rule Categories
The system supports 14 rule categories:
1. **Client** - Client-related settings
2. **Faction** - Faction system rules
3. **Guild** - Guild system rules
4. **Player** - Player-related rules (levels, stats, etc.)
5. **PVP** - Player vs Player combat rules
6. **Combat** - Combat system rules
7. **Spawn** - NPC/spawn behavior rules
8. **UI** - User interface rules
9. **World** - Server-wide settings
10. **Zone** - Zone-specific rules
11. **Loot** - Loot system rules
12. **Spells** - Spell system rules
13. **Expansion** - Expansion flags
14. **Discord** - Discord integration settings
## Usage
### Basic Usage
```go
// Create rule manager
ruleManager := rules.NewRuleManager()
// Get a rule value
rule := ruleManager.GetGlobalRule(rules.CategoryPlayer, rules.PlayerMaxLevel)
maxLevel := rule.GetInt32() // Returns 50 (default)
// Get rule by name
rule2 := ruleManager.GetGlobalRuleByName("Player", "MaxLevel")
```
### Database Integration
```go
// Create database service
db, _ := database.Open("rules.db")
dbService := rules.NewDatabaseService(db)
// Create tables
dbService.CreateRulesTables()
// Load rules from database
dbService.LoadRuleSets(ruleManager, false)
```
### Rule Adapters
```go
// Create adapter for zone-specific rules
adapter := rules.NewRuleManagerAdapter(ruleManager, zoneID)
// Get rule with zone override
maxLevel := adapter.GetInt32(rules.CategoryPlayer, rules.PlayerMaxLevel)
```
## Default Rules
The system includes comprehensive default rules matching the original C++ implementation:
- **Player Max Level**: 50
- **Combat Max Range**: 4.0
- **Experience Multiplier**: 1.0
- **And 100+ other rules across all categories**
## Database Schema
The system uses three tables:
- `rulesets` - Rule set definitions
- `ruleset_details` - Individual rule overrides per rule set
- `variables` - System variables including default rule set ID
## Thread Safety
All operations are thread-safe using Go's sync.RWMutex for optimal read performance.
## Performance
- Rule access: ~280ns per operation (benchmark)
- Rule manager creation: ~45μs per operation (benchmark)
- All operations are optimized for high-frequency access
## Testing
Run the comprehensive test suite:
```bash
go test ./internal/rules/ -v
```
## Migration from C++
This is a complete conversion from the original C++ implementation:
- `Rules.h``constants.go` + `types.go`
- `Rules.cpp``manager.go`
- `RulesDB.cpp``database.go`
All functionality has been preserved with Go-native patterns and improvements:
- Better error handling
- Type safety
- Comprehensive interfaces
- Modern testing practices
- Performance optimizations

File diff suppressed because it is too large Load Diff

View File

@ -1,904 +0,0 @@
package sign
import (
"fmt"
"strings"
"sync"
"testing"
"eq2emu/internal/spawn"
)
// Mock implementations for testing
// MockLogger implements the Logger interface for testing
type MockLogger struct {
logs []string
mu sync.Mutex
}
func (ml *MockLogger) LogInfo(message string, args ...any) {
ml.mu.Lock()
defer ml.mu.Unlock()
ml.logs = append(ml.logs, fmt.Sprintf("INFO: "+message, args...))
}
func (ml *MockLogger) LogError(message string, args ...any) {
ml.mu.Lock()
defer ml.mu.Unlock()
ml.logs = append(ml.logs, fmt.Sprintf("ERROR: "+message, args...))
}
func (ml *MockLogger) LogDebug(message string, args ...any) {
ml.mu.Lock()
defer ml.mu.Unlock()
ml.logs = append(ml.logs, fmt.Sprintf("DEBUG: "+message, args...))
}
func (ml *MockLogger) LogWarning(message string, args ...any) {
ml.mu.Lock()
defer ml.mu.Unlock()
ml.logs = append(ml.logs, fmt.Sprintf("WARNING: "+message, args...))
}
func (ml *MockLogger) GetLogs() []string {
ml.mu.Lock()
defer ml.mu.Unlock()
result := make([]string, len(ml.logs))
copy(result, ml.logs)
return result
}
func (ml *MockLogger) Clear() {
ml.mu.Lock()
defer ml.mu.Unlock()
ml.logs = ml.logs[:0]
}
// MockDatabase implements the Database interface for testing
type MockDatabase struct {
signs map[int32][]*Sign
zoneNames map[int32]string
charNames map[int32]string
signMarks map[int32]map[int32]string // charID -> widgetID -> charName
mu sync.RWMutex
}
func NewMockDatabase() *MockDatabase {
return &MockDatabase{
signs: make(map[int32][]*Sign),
zoneNames: make(map[int32]string),
charNames: make(map[int32]string),
signMarks: make(map[int32]map[int32]string),
}
}
func (md *MockDatabase) GetZoneName(zoneID int32) (string, error) {
md.mu.RLock()
defer md.mu.RUnlock()
if name, exists := md.zoneNames[zoneID]; exists {
return name, nil
}
return "", fmt.Errorf("zone %d not found", zoneID)
}
func (md *MockDatabase) GetCharacterName(charID int32) (string, error) {
md.mu.RLock()
defer md.mu.RUnlock()
if name, exists := md.charNames[charID]; exists {
return name, nil
}
return "", fmt.Errorf("character %d not found", charID)
}
func (md *MockDatabase) SaveSignMark(charID int32, widgetID int32, charName string, client Client) error {
md.mu.Lock()
defer md.mu.Unlock()
if md.signMarks[charID] == nil {
md.signMarks[charID] = make(map[int32]string)
}
md.signMarks[charID][widgetID] = charName
return nil
}
func (md *MockDatabase) LoadSigns(zoneID int32) ([]*Sign, error) {
md.mu.RLock()
defer md.mu.RUnlock()
return md.signs[zoneID], nil
}
func (md *MockDatabase) SaveSign(sign *Sign) error {
return nil // No-op for testing
}
func (md *MockDatabase) DeleteSign(signID int32) error {
return nil // No-op for testing
}
func (md *MockDatabase) AddZone(zoneID int32, name string) {
md.mu.Lock()
defer md.mu.Unlock()
md.zoneNames[zoneID] = name
}
func (md *MockDatabase) AddCharacter(charID int32, name string) {
md.mu.Lock()
defer md.mu.Unlock()
md.charNames[charID] = name
}
func (md *MockDatabase) AddSignToZone(zoneID int32, sign *Sign) {
md.mu.Lock()
defer md.mu.Unlock()
md.signs[zoneID] = append(md.signs[zoneID], sign)
}
// MockPlayer implements the Player interface for testing
type MockPlayer struct {
x, y, z, heading float32
zone Zone
target *spawn.Spawn
}
func (mp *MockPlayer) GetDistance(target *spawn.Spawn) float32 {
if target == nil {
return 0
}
return 5.0 // Mock distance
}
func (mp *MockPlayer) SetX(x float32) { mp.x = x }
func (mp *MockPlayer) SetY(y float32) { mp.y = y }
func (mp *MockPlayer) SetZ(z float32) { mp.z = z }
func (mp *MockPlayer) SetHeading(heading float32) { mp.heading = heading }
func (mp *MockPlayer) GetZone() Zone { return mp.zone }
func (mp *MockPlayer) GetTarget() *spawn.Spawn { return mp.target }
// MockZone implements the Zone interface for testing
type MockZone struct {
transporters map[int32][]TransportDestination
}
func (mz *MockZone) GetTransporters(client Client, transporterID int32) ([]TransportDestination, error) {
if transporters, exists := mz.transporters[transporterID]; exists {
return transporters, nil
}
return nil, fmt.Errorf("transporter %d not found", transporterID)
}
func (mz *MockZone) ProcessEntityCommand(command *EntityCommand, player Player, target *spawn.Spawn) error {
return nil // No-op for testing
}
// MockClient implements the Client interface for testing
type MockClient struct {
player Player
charID int32
database Database
zone Zone
messages []string
tempTransportID int32
zoneAccess map[string]bool
mu sync.Mutex
}
func (mc *MockClient) GetPlayer() Player { return mc.player }
func (mc *MockClient) GetCharacterID() int32 { return mc.charID }
func (mc *MockClient) GetDatabase() Database { return mc.database }
func (mc *MockClient) GetCurrentZone() Zone { return mc.zone }
func (mc *MockClient) SetTemporaryTransportID(id int32) { mc.tempTransportID = id }
func (mc *MockClient) SimpleMessage(channel int32, message string) {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.messages = append(mc.messages, message)
}
func (mc *MockClient) Message(channel int32, format string, args ...any) {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.messages = append(mc.messages, fmt.Sprintf(format, args...))
}
func (mc *MockClient) CheckZoneAccess(zoneName string) bool {
return mc.zoneAccess[zoneName]
}
func (mc *MockClient) TryZoneInstance(zoneID int32, useDefaults bool) bool {
return false // Mock always uses regular zones
}
func (mc *MockClient) Zone(zoneName string, useDefaults bool) error {
return nil // No-op for testing
}
func (mc *MockClient) ProcessTeleport(sign *Sign, destinations []TransportDestination, transporterID int32) error {
return nil // No-op for testing
}
func (mc *MockClient) GetMessages() []string {
mc.mu.Lock()
defer mc.mu.Unlock()
result := make([]string, len(mc.messages))
copy(result, mc.messages)
return result
}
func (mc *MockClient) ClearMessages() {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.messages = mc.messages[:0]
}
// MockEntity implements the Entity interface for testing
type MockEntity struct {
id int32
name string
databaseID int32
}
func (me *MockEntity) GetID() int32 { return me.id }
func (me *MockEntity) GetName() string { return me.name }
func (me *MockEntity) GetDatabaseID() int32 { return me.databaseID }
// Helper functions for testing
func createTestSign() *Sign {
sign := NewSign()
sign.Spawn.SetDatabaseID(1)
sign.Spawn.SetName("Test Sign")
sign.SetWidgetID(100)
sign.SetSignTitle("Welcome")
sign.SetSignDescription("This is a test sign")
return sign
}
func createZoneSign() *Sign {
sign := createTestSign()
sign.SetSignType(SignTypeZone)
sign.SetSignZoneID(2)
sign.SetSignZoneX(100.0)
sign.SetSignZoneY(200.0)
sign.SetSignZoneZ(300.0)
sign.SetSignZoneHeading(45.0)
return sign
}
// Tests
func TestNewSign(t *testing.T) {
sign := NewSign()
if sign == nil {
t.Fatal("NewSign() returned nil")
}
if sign.Spawn == nil {
t.Error("Sign spawn is nil")
}
if sign.GetSignType() != SignTypeGeneric {
t.Errorf("Expected sign type %d, got %d", SignTypeGeneric, sign.GetSignType())
}
if !sign.IsSign() {
t.Error("IsSign() should return true")
}
if sign.IsZoneSign() {
t.Error("IsZoneSign() should return false for generic sign")
}
if !sign.IsGenericSign() {
t.Error("IsGenericSign() should return true for generic sign")
}
}
func TestSignProperties(t *testing.T) {
sign := NewSign()
// Test widget properties
sign.SetWidgetID(123)
if sign.GetWidgetID() != 123 {
t.Errorf("Expected widget ID 123, got %d", sign.GetWidgetID())
}
sign.SetWidgetX(10.5)
if sign.GetWidgetX() != 10.5 {
t.Errorf("Expected widget X 10.5, got %f", sign.GetWidgetX())
}
// Test sign properties
sign.SetSignTitle("Test Title")
if sign.GetSignTitle() != "Test Title" {
t.Errorf("Expected title 'Test Title', got '%s'", sign.GetSignTitle())
}
sign.SetSignDescription("Test Description")
if sign.GetSignDescription() != "Test Description" {
t.Errorf("Expected description 'Test Description', got '%s'", sign.GetSignDescription())
}
// Test zone properties
sign.SetSignZoneID(456)
if sign.GetSignZoneID() != 456 {
t.Errorf("Expected zone ID 456, got %d", sign.GetSignZoneID())
}
sign.SetSignZoneX(100.0)
sign.SetSignZoneY(200.0)
sign.SetSignZoneZ(300.0)
sign.SetSignZoneHeading(45.0)
if !sign.HasZoneCoordinates() {
t.Error("Should have zone coordinates")
}
// Test display options
sign.SetIncludeLocation(true)
if !sign.GetIncludeLocation() {
t.Error("Include location should be true")
}
}
func TestSignValidation(t *testing.T) {
// Valid sign
sign := createTestSign()
if !sign.IsValid() {
t.Error("Sign should be valid")
}
issues := sign.Validate()
if len(issues) > 0 {
t.Errorf("Valid sign should have no issues, got: %v", issues)
}
// Invalid sign - no widget ID
sign.SetWidgetID(0)
if sign.IsValid() {
t.Error("Sign without widget ID should be invalid")
}
issues = sign.Validate()
if len(issues) == 0 {
t.Error("Sign without widget ID should have validation issues")
}
// Invalid sign - title too long
sign.SetWidgetID(100)
sign.SetSignTitle(strings.Repeat("a", MaxSignTitleLength+1))
issues = sign.Validate()
found := false
for _, issue := range issues {
if strings.Contains(issue, "title too long") {
found = true
break
}
}
if !found {
t.Error("Should have title too long validation issue")
}
// Invalid zone sign - no zone ID
sign = createTestSign()
sign.SetSignType(SignTypeZone)
// Don't set zone ID
issues = sign.Validate()
found = false
for _, issue := range issues {
if strings.Contains(issue, "no zone ID") {
found = true
break
}
}
if !found {
t.Error("Zone sign without zone ID should have validation issue")
}
}
func TestSignCopy(t *testing.T) {
original := createTestSign()
original.SetSignTitle("Original Title")
original.SetSignDescription("Original Description")
copy := original.Copy()
if copy == nil {
t.Fatal("Copy returned nil")
}
if copy == original {
t.Error("Copy should not be the same instance")
}
if copy.GetSignTitle() != original.GetSignTitle() {
t.Error("Copy should have same title")
}
if copy.GetSignDescription() != original.GetSignDescription() {
t.Error("Copy should have same description")
}
if copy.GetWidgetID() != original.GetWidgetID() {
t.Error("Copy should have same widget ID")
}
// Modify copy and ensure original is unchanged
copy.SetSignTitle("Modified Title")
if original.GetSignTitle() == "Modified Title" {
t.Error("Modifying copy should not affect original")
}
}
func TestSignDisplayText(t *testing.T) {
sign := NewSign()
// Empty sign
text := sign.GetDisplayText()
if len(text) > 0 {
t.Error("Empty sign should have no display text")
}
// Title only
sign.SetSignTitle("Test Title")
text = sign.GetDisplayText()
if text != "Test Title" {
t.Errorf("Expected 'Test Title', got '%s'", text)
}
// Title and description
sign.SetSignDescription("Test Description")
text = sign.GetDisplayText()
expected := "Test Title\nTest Description"
if text != expected {
t.Errorf("Expected '%s', got '%s'", expected, text)
}
// With location
sign.SetSignZoneX(100.0)
sign.SetSignZoneY(200.0)
sign.SetSignZoneZ(300.0)
sign.SetIncludeLocation(true)
text = sign.GetDisplayText()
if !strings.Contains(text, "Location: 100.00, 200.00, 300.00") {
t.Error("Display text should contain location information")
}
// With heading
sign.SetSignZoneHeading(45.0)
sign.SetIncludeHeading(true)
text = sign.GetDisplayText()
if !strings.Contains(text, "Heading: 45.00") {
t.Error("Display text should contain heading information")
}
}
func TestSignHandleUse(t *testing.T) {
// Setup test environment
database := NewMockDatabase()
database.AddZone(2, "Test Zone")
database.AddCharacter(1, "TestPlayer")
player := &MockPlayer{}
client := &MockClient{
player: player,
charID: 1,
database: database,
zoneAccess: map[string]bool{"Test Zone": true},
}
// Test zone transport sign
sign := createZoneSign()
sign.SetSignDistance(10.0) // Set distance limit
err := sign.HandleUse(client, "")
if err != nil {
t.Errorf("HandleUse failed: %v", err)
}
// Check if player position was set
if player.x != 100.0 || player.y != 200.0 || player.z != 300.0 {
t.Error("Player position should be updated for zone transport")
}
if player.heading != 45.0 {
t.Error("Player heading should be updated for zone transport")
}
// Test mark command
sign = createTestSign()
err = sign.HandleUse(client, "mark")
if err != nil {
t.Errorf("Mark command failed: %v", err)
}
// Test distance check
sign = createZoneSign()
sign.SetSignDistance(1.0) // Very short distance
client.ClearMessages()
err = sign.HandleUse(client, "")
if err != nil {
t.Errorf("HandleUse with distance check failed: %v", err)
}
messages := client.GetMessages()
found := false
for _, msg := range messages {
if strings.Contains(msg, "too far away") {
found = true
break
}
}
if !found {
t.Error("Should get 'too far away' message when outside distance")
}
}
func TestSignSpawn(t *testing.T) {
baseSpawn := spawn.NewSpawn()
baseSpawn.SetName("Test Sign Spawn")
signSpawn := NewSignSpawn(baseSpawn)
if signSpawn == nil {
t.Fatal("NewSignSpawn returned nil")
}
if !signSpawn.IsSign() {
t.Error("SignSpawn should be a sign")
}
if signSpawn.Spawn != baseSpawn {
t.Error("SignSpawn should reference the base spawn")
}
// Test copy
copy := signSpawn.Copy()
if copy == nil {
t.Fatal("SignSpawn Copy returned nil")
}
if copy == signSpawn {
t.Error("SignSpawn Copy should create new instance")
}
if copy.Spawn.GetName() != baseSpawn.GetName() {
t.Error("Copied SignSpawn should have same spawn name")
}
}
func TestSignAdapter(t *testing.T) {
logger := &MockLogger{}
entity := &MockEntity{id: 1, name: "Test Entity", databaseID: 100}
adapter := NewSignAdapter(entity, logger)
if adapter == nil {
t.Fatal("NewSignAdapter returned nil")
}
if !adapter.IsSign() {
t.Error("SignAdapter should be a sign")
}
if adapter.GetSign() == nil {
t.Error("SignAdapter should have a sign")
}
// Test setting properties with logging
adapter.GetSign().SetWidgetID(500) // Set widget ID for validation
adapter.SetSignTitle("Test Title")
adapter.SetSignDescription("Test Description")
adapter.SetSignType(SignTypeZone)
adapter.SetZoneTransport(2, 100.0, 200.0, 300.0, 45.0)
adapter.SetSignDistance(15.0)
logs := logger.GetLogs()
if len(logs) == 0 {
t.Error("SignAdapter operations should generate log messages")
}
// Verify sign properties were set
sign := adapter.GetSign()
if sign.GetSignTitle() != "Test Title" {
t.Error("Sign title not set correctly")
}
if sign.GetSignType() != SignTypeZone {
t.Error("Sign type not set correctly")
}
if sign.GetSignZoneID() != 2 {
t.Error("Zone ID not set correctly")
}
// Test validation
if !adapter.IsValid() {
issues := adapter.Validate()
t.Errorf("SignAdapter should be valid, issues: %v", issues)
}
}
func TestManager(t *testing.T) {
database := NewMockDatabase()
logger := &MockLogger{}
manager := NewManager(database, logger)
if manager == nil {
t.Fatal("NewManager returned nil")
}
// Test initialization
err := manager.Initialize()
if err != nil {
t.Errorf("Manager initialization failed: %v", err)
}
// Test adding signs
sign1 := createTestSign()
sign1.Spawn.SetDatabaseID(1)
sign1.SetWidgetID(100)
err = manager.AddSign(sign1)
if err != nil {
t.Errorf("Adding sign failed: %v", err)
}
if manager.GetSignCount() != 1 {
t.Errorf("Expected 1 sign, got %d", manager.GetSignCount())
}
// Test retrieving signs
retrieved := manager.GetSign(1)
if retrieved == nil {
t.Error("Should be able to retrieve sign by ID")
}
retrieved = manager.GetSignByWidget(100)
if retrieved == nil {
t.Error("Should be able to retrieve sign by widget ID")
}
// Test zone functionality
sign2 := createZoneSign()
sign2.Spawn.SetDatabaseID(2)
sign2.SetWidgetID(200)
err = manager.AddSignToZone(sign2, 1)
if err != nil {
t.Errorf("Adding sign to zone failed: %v", err)
}
zoneSigns := manager.GetZoneSigns(1)
if len(zoneSigns) != 1 {
t.Errorf("Expected 1 zone sign, got %d", len(zoneSigns))
}
// Test statistics
stats := manager.GetStatistics()
if stats["total_signs"] != int64(2) {
t.Errorf("Expected 2 total signs in stats, got %v", stats["total_signs"])
}
typeStats := stats["signs_by_type"].(map[int8]int64)
if typeStats[SignTypeGeneric] != 1 {
t.Errorf("Expected 1 generic sign in stats, got %d", typeStats[SignTypeGeneric])
}
if typeStats[SignTypeZone] != 1 {
t.Errorf("Expected 1 zone sign in stats, got %d", typeStats[SignTypeZone])
}
// Test removing signs
if !manager.RemoveSign(1) {
t.Error("Should be able to remove sign")
}
if manager.GetSignCount() != 1 {
t.Errorf("Expected 1 sign after removal, got %d", manager.GetSignCount())
}
// Test validation
issues := manager.ValidateAllSigns()
if len(issues) > 0 {
t.Errorf("All signs should be valid, found issues: %v", issues)
}
// Test commands
result, err := manager.ProcessCommand("stats", nil)
if err != nil {
t.Errorf("Stats command failed: %v", err)
}
if !strings.Contains(result, "Sign System Statistics") {
t.Error("Stats command should return statistics")
}
result, err = manager.ProcessCommand("list", nil)
if err != nil {
t.Errorf("List command failed: %v", err)
}
if !strings.Contains(result, "Signs (1)") {
t.Error("List command should show sign count")
}
result, err = manager.ProcessCommand("validate", nil)
if err != nil {
t.Errorf("Validate command failed: %v", err)
}
if !strings.Contains(result, "All signs are valid") {
t.Error("Validate command should report all signs valid")
}
result, err = manager.ProcessCommand("info", []string{"2"})
if err != nil {
t.Errorf("Info command failed: %v", err)
}
if !strings.Contains(result, "Sign Information") {
t.Error("Info command should show sign information")
}
// Test unknown command
_, err = manager.ProcessCommand("unknown", nil)
if err == nil {
t.Error("Unknown command should return error")
}
}
func TestManagerConcurrency(t *testing.T) {
database := NewMockDatabase()
logger := &MockLogger{}
manager := NewManager(database, logger)
const numGoroutines = 10
const numOperations = 100
var wg sync.WaitGroup
wg.Add(numGoroutines)
// Test concurrent sign additions
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
sign := createTestSign()
signID := int32(id*numOperations + j + 1)
sign.Spawn.SetDatabaseID(signID)
sign.SetWidgetID(signID)
err := manager.AddSign(sign)
if err != nil {
t.Errorf("Concurrent AddSign failed: %v", err)
}
}
}(i)
}
wg.Wait()
expectedCount := int64(numGoroutines * numOperations)
if manager.GetSignCount() != expectedCount {
t.Errorf("Expected %d signs after concurrent additions, got %d",
expectedCount, manager.GetSignCount())
}
// Test concurrent reads
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < numOperations; j++ {
stats := manager.GetStatistics()
if stats["total_signs"].(int64) <= 0 {
t.Error("Stats should show positive sign count")
}
}
}()
}
wg.Wait()
}
func TestSignSerialization(t *testing.T) {
sign := createTestSign()
player := &MockPlayer{}
// Test serialization (currently returns error due to incomplete implementation)
_, err := sign.Serialize(player, 1146)
if err == nil {
t.Error("Serialization should return error with current implementation")
}
if !strings.Contains(err.Error(), "not yet implemented") {
t.Error("Should get 'not yet implemented' error message")
}
}
func TestSignConstants(t *testing.T) {
// Test sign type constants
if SignTypeGeneric != 0 {
t.Errorf("SignTypeGeneric should be 0, got %d", SignTypeGeneric)
}
if SignTypeZone != 1 {
t.Errorf("SignTypeZone should be 1, got %d", SignTypeZone)
}
// Test default constants
if DefaultSpawnType != 2 {
t.Errorf("DefaultSpawnType should be 2, got %d", DefaultSpawnType)
}
if DefaultActivityStatus != 64 {
t.Errorf("DefaultActivityStatus should be 64, got %d", DefaultActivityStatus)
}
if DefaultSignDistance != 0.0 {
t.Errorf("DefaultSignDistance should be 0.0, got %f", DefaultSignDistance)
}
}
// Benchmark tests
func BenchmarkNewSign(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = NewSign()
}
}
func BenchmarkSignCopy(b *testing.B) {
sign := createTestSign()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = sign.Copy()
}
}
func BenchmarkSignValidation(b *testing.B) {
sign := createTestSign()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = sign.Validate()
}
}
func BenchmarkManagerAddSign(b *testing.B) {
database := NewMockDatabase()
logger := &MockLogger{}
manager := NewManager(database, logger)
b.ResetTimer()
for i := 0; i < b.N; i++ {
sign := createTestSign()
sign.Spawn.SetDatabaseID(int32(i + 1))
sign.SetWidgetID(int32(i + 100))
_ = manager.AddSign(sign)
}
}
func BenchmarkManagerGetSign(b *testing.B) {
database := NewMockDatabase()
logger := &MockLogger{}
manager := NewManager(database, logger)
// Pre-populate with signs
for i := 0; i < 1000; i++ {
sign := createTestSign()
sign.Spawn.SetDatabaseID(int32(i + 1))
sign.SetWidgetID(int32(i + 100))
_ = manager.AddSign(sign)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = manager.GetSign(int32((i % 1000) + 1))
}
}

View File

@ -1,166 +0,0 @@
# Spawn System
This package implements the EverQuest II spawn system, converted from the original C++ codebase to Go.
## Overview
The spawn system manages all entities that can appear in the game world, including NPCs, objects, widgets, signs, and ground spawns. It handles their positioning, movement, combat states, and interactions.
## Key Components
### Spawn (`spawn.go`)
The `Spawn` struct is the base class for all entities in the game world. It provides:
- **Basic Properties**: ID, name, level, position, heading
- **State Management**: Health, power, alive status, combat state
- **Movement System**: Position tracking, movement queues, pathfinding
- **Command System**: Interactive commands players can use
- **Loot System**: Item drops, coin rewards, loot distribution
- **Scripting Integration**: Lua script support for dynamic behavior
- **Thread Safety**: Atomic operations and mutexes for concurrent access
Key features:
- Thread-safe operations using atomic values and mutexes
- Extensible design allowing subclasses (NPC, Player, Object, etc.)
- Comprehensive state tracking with change notifications
- Support for temporary variables for Lua scripting
- Equipment and appearance management
- Group and faction relationships
### Spawn Lists (`spawn_lists.go`)
Manages spawn locations and spawn entries:
- **SpawnEntry**: Defines what can spawn at a location with configuration
- **SpawnLocation**: Represents a point in the world where spawns appear
- **SpawnLocationManager**: Manages collections of spawn locations
Features:
- Randomized spawn selection based on percentages
- Position offsets for spawn variety
- Respawn timing with random offsets
- Stat overrides for spawn customization
- Grid-based location management
## Architecture Notes
### Thread Safety
The spawn system is designed for high-concurrency access:
- Atomic values for frequently-accessed state flags
- Read-write mutexes for complex data structures
- Separate mutexes for different subsystems to minimize contention
### Memory Management
Go's garbage collector handles memory management, but the system includes:
- Proper cleanup methods for complex structures
- Resource pooling where appropriate
- Efficient data structures to minimize allocations
### Extensibility
The base Spawn struct is designed to be extended:
- Virtual method patterns using interfaces
- Type checking methods (IsNPC, IsPlayer, etc.)
- Extensible command and scripting systems
## TODO Items
Many features are marked with TODO comments for future implementation:
### High Priority
- **Zone System Integration**: Zone server references and notifications
- **Client System**: Player client connections and packet handling
- **Item System**: Complete item and equipment implementation
- **Combat System**: Damage calculation and combat mechanics
### Medium Priority
- **Lua Scripting**: Full Lua integration for spawn behavior
- **Movement System**: Pathfinding and advanced movement
- **Quest System**: Quest requirement checking and progression
- **Map System**: Collision detection and height maps
### Low Priority
- **Region System**: Area-based effects and triggers
- **Housing System**: Player housing integration
- **Transportation**: Mounts and vehicles
- **Advanced Physics**: Knockback and projectile systems
## Usage Examples
### Creating a Basic Spawn
```go
spawn := NewSpawn()
spawn.SetName("a goblin warrior")
spawn.SetLevel(10)
spawn.SetX(100.0)
spawn.SetY(0.0)
spawn.SetZ(200.0)
spawn.SetTotalHP(500)
spawn.SetHP(500)
```
### Managing Spawn Locations
```go
manager := NewSpawnLocationManager()
location := NewSpawnLocation()
location.SetPosition(100.0, 0.0, 200.0)
location.SetOffsets(5.0, 0.0, 5.0) // 5 unit random offset
entry := NewSpawnEntry()
entry.SpawnID = 12345
entry.SpawnPercentage = 75.0
entry.Respawn = 300 // 5 minutes
location.AddSpawnEntry(entry)
manager.AddLocation(1001, location)
```
### Adding Commands
```go
spawn.AddPrimaryEntityCommand(
"hail", // command name
10.0, // max distance
"hail_npc", // internal command
"Too far away", // error message
0, // cast time
0, // spell visual
true, // default allow
)
```
## Integration Points
The spawn system integrates with several other systems:
- **Database**: Loading and saving spawn data
- **Network**: Serializing spawn data for clients
- **Zone**: Spawn management within zones
- **Combat**: Damage and death handling
- **Scripting**: Lua event handling
- **Items**: Equipment and loot management
## Performance Considerations
- Position updates use atomic operations to minimize locking
- Command lists are copied on access to prevent race conditions
- Spawn changes are batched and sent efficiently to clients
- Memory usage is optimized for large numbers of concurrent spawns
## Migration from C++
This Go implementation maintains compatibility with the original C++ EQ2EMu spawn system while modernizing the architecture:
- Converted C-style arrays to Go slices
- Replaced manual memory management with garbage collection
- Used Go's concurrency primitives instead of platform-specific threading
- Maintained the same packet structures and network protocol
- Preserved the same database schema and data relationships
The API surface remains similar to ease porting of existing scripts and configurations.

View File

@ -1,68 +0,0 @@
# Spells System
Complete spell system for EverQuest II server emulation with spell definitions, casting mechanics, effects, and processing.
## Components
**Core System:**
- **SpellData/Spell** - Spell definitions with properties, levels, effects, LUA data
- **SpellEffectManager** - Active effects management (buffs, debuffs, bonuses)
- **MasterSpellList/SpellBook** - Global registry and per-character spell collections
**Spell Processing:**
- **SpellProcess** - Real-time casting engine (50ms intervals) with timers, queues, interrupts
- **SpellTargeting** - All target types: self, single, group, AOE, PBAE with validation
- **SpellResourceChecker** - Power, health, concentration, savagery, dissonance management
- **SpellManager** - High-level coordinator integrating all systems
## Key Features
- **Real-time Processing**: Cast/recast timers, active spell tracking, interrupt handling
- **Comprehensive Targeting**: Range, LOS, spell criteria validation for all target types
- **Resource Management**: All EQ2 resource types with validation and consumption
- **Effect System**: 30 maintained effects, 45 spell effects, detrimental effects
- **Heroic Opportunities**: Solo/group coordination with timing
- **Thread Safety**: Concurrent access with proper mutexes
- **80+ Effect Types**: All spell modifications from original C++ system
## Usage
```go
// Create and use spell manager
spellManager := spells.NewSpellManager()
// Cast spell with full validation
err := spellManager.CastSpell(casterID, targetID, spellID)
if err != nil {
log.Printf("Spell failed: %v", err)
}
// Check spell readiness
canCast, reason := spellManager.CanCastSpell(casterID, targetID, spellID)
// Process spells in main loop
spellManager.ProcessSpells()
// Manage spell books
spellBook := spellManager.GetSpellBook(characterID)
spellBook.AddSpell(spell)
spellBook.SetSpellBarSlot(0, 1, spell)
// Effect management
sem := spells.NewSpellEffectManager()
sem.AddMaintainedEffect(maintainedEffect)
sem.AddSpellEffect(tempEffect)
```
## Files
**Core**: `constants.go`, `spell_data.go`, `spell.go`, `spell_effects.go`, `spell_manager.go`
**Processing**: `process_constants.go`, `spell_process.go`, `spell_targeting.go`, `spell_resources.go`
**Docs**: `README.md`, `SPELL_PROCESS.md`
## Integration
**Database**: Spell data loading, character spell book persistence
**Packet System**: Spell info serialization, effect updates
**Entity System**: SpellEffectManager embedded, stat integration
**LUA Scripting**: Custom spell behaviors, effect calculations

View File

@ -1,112 +0,0 @@
# Spell Processing System
Comprehensive spell casting engine managing all aspects of spell processing including timers, targeting, resource management, and heroic opportunities.
## Components
**SpellProcess** - Core engine managing active spells, cast/recast timers, interrupt queues, spell queues, and heroic opportunities
**SpellTargeting** - Target selection for all spell types (self, single, group, AOE, PBAE) with validation
**SpellResourceChecker** - Resource validation/consumption for power, health, concentration, savagery, dissonance
**SpellManager** - High-level coordinator integrating all systems with comprehensive casting API
## Key Data Structures
```go
// Active spell instance
type LuaSpell struct {
Spell *Spell // Spell definition
CasterID int32 // Casting entity
Targets []int32 // Target list
Timer SpellTimer // Duration/tick timing
NumCalls int32 // Tick count
Interrupted bool // Interrupt state
}
// Cast timing
type CastTimer struct {
CasterID int32 // Casting entity
SpellID int32 // Spell being cast
StartTime time.Time // Cast start
Duration time.Duration // Cast time
}
// Cooldown timing
type RecastTimer struct {
CasterID int32 // Entity with cooldown
SpellID int32 // Spell on cooldown
Duration time.Duration // Cooldown time
LinkedTimer int32 // Shared cooldown group
}
```
## Usage
```go
// Main spell processing loop (50ms intervals)
spellManager.ProcessSpells()
// Cast spell with full validation
err := spellManager.CastSpell(casterID, targetID, spellID)
// Interrupt casting
spellManager.InterruptSpell(entityID, spellID, errorCode, canceled, fromMovement)
// Queue management
spellManager.AddSpellToQueue(spellID, casterID, targetID, priority)
spellManager.RemoveSpellFromQueue(spellID, casterID)
// Status checks
ready := spellManager.IsSpellReady(spellID, casterID)
recastTime := spellManager.GetSpellRecastTime(spellID, casterID)
canCast, reason := spellManager.CanCastSpell(casterID, targetID, spellID)
// Resource checking
resourceChecker := spellManager.GetResourceChecker()
powerResult := resourceChecker.CheckPower(luaSpell, customPowerReq)
allResults := resourceChecker.CheckAllResources(luaSpell, 0, 0)
success := resourceChecker.ConsumeAllResources(luaSpell, 0, 0)
// Targeting
targeting := spellManager.GetTargeting()
targetResult := targeting.GetSpellTargets(luaSpell, options)
```
## Effect Types (80+)
**Stat Modifications**: Health, power, stats (STR/AGI/STA/INT/WIS), resistances, attack, mitigation
**Spell Modifications**: Cast time, power req, range, duration, resistibility, crit chance
**Actions**: Damage, healing, DOT/HOT, resurrect, summon, mount, invisibility
**Control**: Stun, root, mez, fear, charm, blind, kill
**Special**: Change race/size/title, faction, exp, tradeskill bonuses
## Target Types
**TargetTypeSelf** (0) - Self-only spells
**TargetTypeSingle** (1) - Single target with validation
**TargetTypeGroup** (2) - Group members
**TargetTypeGroupAE** (3) - Group area effect
**TargetTypeAE** (4) - True area effect
**TargetTypePBAE** (5) - Point blank area effect
## Interrupt System
**Causes**: Movement, damage, stun, mesmerize, fear, manual cancellation, out of range
**Processing**: Queued interrupts processed every cycle with proper cleanup
**Error Codes**: Match client expectations for proper UI feedback
## Performance
- **50ms Processing**: Matches client update expectations
- **Efficient Indexing**: Fast lookups by caster, spell, target
- **Thread Safety**: Concurrent access with proper locking
- **Memory Management**: Cleanup of expired timers and effects
- **Batch Operations**: Multiple resource checks/targeting in single calls
## Integration Points
**Entity System**: Caster/target info, position data, combat state
**Zone System**: Position validation, line-of-sight, spawn management
**Group System**: Group member targeting and coordination
**Database**: Persistent spell data, character spell books
**Packet System**: Client communication for spell states
**LUA System**: Custom spell scripting (future)

View File

@ -1,20 +0,0 @@
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

@ -1,226 +0,0 @@
# Title System
The title system manages character titles in the EverQuest II server emulator, allowing players to earn, display, and manage various titles that represent their achievements and progression.
## Overview
The title system consists of several key components:
- **Master Titles List**: Global registry of all available titles
- **Player Title Collections**: Individual player title ownership and preferences
- **Title Manager**: Central coordination and management
- **Integration Systems**: Hooks for earning titles through various game activities
## Core Components
### Title Structure
Each title has the following properties:
- **ID**: Unique identifier
- **Name**: Display name of the title
- **Description**: Detailed description shown in UI
- **Position**: Whether it appears as prefix or suffix
- **Category**: Organizational category (Combat, Tradeskill, etc.)
- **Source**: How the title is obtained (Achievement, Quest, etc.)
- **Rarity**: Common, Uncommon, Rare, Epic, Legendary, Unique
- **Requirements**: Conditions that must be met to earn the title
- **Flags**: Various behavioral modifiers (Hidden, Temporary, Unique, etc.)
### Title Positioning
Titles can be displayed in two positions relative to the character name:
- **Prefix**: Appears before the character name (e.g., "Master John")
- **Suffix**: Appears after the character name (e.g., "John the Brave")
Players can have one active prefix and one active suffix title simultaneously.
### Title Sources
Titles can be obtained through various means:
- **Achievements**: Completing specific achievements
- **Quests**: Finishing particular quest lines
- **Tradeskills**: Reaching mastery in crafting
- **Combat**: Battle-related accomplishments
- **Exploration**: Discovering new areas
- **PvP**: Player vs Player activities
- **Guild**: Guild progression and achievements
- **Events**: Special server events
- **Rare**: Uncommon encounters or collections
## Usage Examples
### Basic Title Management
```go
// Create a title manager
titleManager := titles.NewTitleManager()
// Create a new title
title, err := titleManager.CreateTitle(
"Dragon Slayer", // name
"Defeated an ancient dragon", // description
titles.CategoryCombat, // category
titles.TitlePositionSuffix, // position
titles.TitleSourceAchievement, // source
titles.TitleRarityEpic, // rarity
)
// Grant a title to a player
err = titleManager.GrantTitle(playerID, titleID, achievementID, 0)
// Set active titles
err = titleManager.SetPlayerActivePrefix(playerID, prefixTitleID)
err = titleManager.SetPlayerActiveSuffix(playerID, suffixTitleID)
// Get formatted player name with titles
formattedName := titleManager.GetPlayerFormattedName(playerID, "John")
// Result: "Master John the Dragon Slayer"
```
### Achievement Integration
```go
// Set up achievement integration
integrationManager := titles.NewIntegrationManager(titleManager)
// Create achievement-linked title
title, err := titleManager.CreateAchievementTitle(
"Dungeon Master",
"Completed 100 dungeons",
achievementID,
titles.TitlePositionPrefix,
titles.TitleRarityRare,
)
// When achievement is completed
err = integrationManager.GetAchievementIntegration().OnAchievementCompleted(playerID, achievementID)
```
### Event Titles
```go
// Start a seasonal event with title reward
eventIntegration := integrationManager.GetEventIntegration()
err = eventIntegration.StartEvent(
"Halloween 2024",
"Spooky seasonal event",
7*24*time.Hour, // 1 week duration
halloweenTitleID,
)
// Grant participation title
err = eventIntegration.OnEventParticipation(playerID, "Halloween 2024")
```
## File Structure
- `constants.go`: Title system constants and enums
- `title.go`: Core title and player title data structures
- `master_list.go`: Global title registry and management
- `player_titles.go`: Individual player title collections
- `title_manager.go`: Central title system coordinator
- `integration.go`: Integration systems for earning titles
- `README.md`: This documentation file
## Database Integration
The title system is designed to integrate with the database layer:
- **Master titles**: Stored in `titles` table
- **Player titles**: Stored in `character_titles` table
- **Title requirements**: Stored in `title_requirements` table
Database methods are marked as TODO and will be implemented when the database package is available.
## Network Packets
The system supports the following network packets:
- **TitleUpdate**: Sends player's available titles to client
- **UpdateTitle**: Updates displayed title information
Packet structures are defined based on the XML definitions in the packets directory.
## Title Categories
The system organizes titles into logical categories:
- **Combat**: Battle and PvP related titles
- **Tradeskill**: Crafting and gathering achievements
- **Exploration**: Zone discovery and travel
- **Social**: Community and roleplay titles
- **Achievement**: General accomplishment titles
- **Quest**: Story and mission completion
- **Rare**: Uncommon encounters and collections
- **Seasonal**: Time-limited event titles
- **Guild**: Organization-based titles
- **Raid**: Group content achievements
- **Class**: Profession-specific titles
- **Race**: Heritage and background titles
## Title Rarity System
Titles have six rarity levels with associated colors:
1. **Common** (White): Easily obtainable titles
2. **Uncommon** (Green): Moderate effort required
3. **Rare** (Blue): Significant achievement needed
4. **Epic** (Purple): Exceptional accomplishment
5. **Legendary** (Orange): Extremely difficult to obtain
6. **Unique** (Red): One-of-a-kind titles
## Special Title Types
### Temporary Titles
Some titles expire after a certain period:
- Event titles that expire when the event ends
- Temporary status titles (e.g., "Newcomer")
- Achievement-based titles with time limits
### Unique Titles
Certain titles can only be held by one player at a time:
- "First to reach max level"
- "Server champion"
- Special recognition titles
### Account-Wide Titles
Some titles are shared across all characters on an account:
- Beta tester recognition
- Special event participation
- Founder rewards
## Thread Safety
All title system components are thread-safe using appropriate synchronization:
- `sync.RWMutex` for read-heavy operations
- Atomic operations where appropriate
- Proper locking hierarchy to prevent deadlocks
## Performance Considerations
- Title lookups are optimized with indexed maps
- Player title data is cached in memory
- Background cleanup processes handle expired titles
- Database operations are batched when possible
## Future Enhancements
Planned improvements include:
- Advanced title search and filtering
- Title display customization options
- Title trading/gifting system
- Dynamic title generation
- Integration with guild systems
- Advanced achievement requirements
- Title collection statistics and tracking

View File

@ -1,865 +0,0 @@
package titles
import (
"fmt"
"path/filepath"
"sync"
"testing"
"time"
)
// Test Title struct creation and basic methods
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 TestTitleFlags(t *testing.T) {
title := NewTitle(1, "Test Title")
// Test setting flags
title.SetFlag(FlagHidden)
if !title.HasFlag(FlagHidden) {
t.Error("Expected title to have FlagHidden after setting")
}
title.SetFlag(FlagTemporary)
if !title.HasFlag(FlagTemporary) {
t.Error("Expected title to have FlagTemporary after setting")
}
// Test clearing flags
title.ClearFlag(FlagHidden)
if title.HasFlag(FlagHidden) {
t.Error("Expected title not to have FlagHidden after clearing")
}
// Test toggle flags (manually implement toggle)
if title.HasFlag(FlagUnique) {
title.ClearFlag(FlagUnique)
} else {
title.SetFlag(FlagUnique)
}
if !title.HasFlag(FlagUnique) {
t.Error("Expected title to have FlagUnique after toggling")
}
// Toggle again
if title.HasFlag(FlagUnique) {
title.ClearFlag(FlagUnique)
} else {
title.SetFlag(FlagUnique)
}
if title.HasFlag(FlagUnique) {
t.Error("Expected title not to have FlagUnique after toggling again")
}
}
func TestTitleProperties(t *testing.T) {
title := NewTitle(1, "Test Title")
// Test description
title.SetDescription("A test description")
if title.Description != "A test description" {
t.Errorf("Expected description 'A test description', got '%s'", title.Description)
}
// Test category
title.SetCategory(CategoryAchievement)
if title.Category != CategoryAchievement {
t.Errorf("Expected category '%s', got '%s'", CategoryAchievement, title.Category)
}
// Test rarity
title.SetRarity(TitleRarityRare)
if title.Rarity != TitleRarityRare {
t.Errorf("Expected rarity %d, got %d", TitleRarityRare, title.Rarity)
}
// Test display name
title.Position = TitlePositionPrefix
displayName := title.GetDisplayName()
if displayName != "Test Title" {
t.Errorf("Expected display name 'Test Title', got '%s'", displayName)
}
}
func TestTitleTypeMethods(t *testing.T) {
title := NewTitle(1, "Test Title")
// Test hidden flag
title.SetFlag(FlagHidden)
if !title.IsHidden() {
t.Error("Expected title to be hidden")
}
// Test temporary flag
title.ClearFlag(FlagHidden)
title.SetFlag(FlagTemporary)
if !title.IsTemporary() {
t.Error("Expected title to be temporary")
}
// Test unique flag
title.ClearFlag(FlagTemporary)
title.SetFlag(FlagUnique)
if !title.IsUnique() {
t.Error("Expected title to be unique")
}
// Test account-wide flag
title.ClearFlag(FlagUnique)
title.SetFlag(FlagAccountWide)
if !title.IsAccountWide() {
t.Error("Expected title to be account-wide")
}
}
// Test MasterTitlesList
func TestMasterTitlesList(t *testing.T) {
mtl := NewMasterTitlesList()
if mtl == nil {
t.Fatal("NewMasterTitlesList returned nil")
}
// Test default titles are loaded
citizen, exists := mtl.GetTitle(TitleIDCitizen)
if !exists || citizen == nil {
t.Error("Expected Citizen title to be loaded by default")
} else if citizen.Name != "Citizen" {
t.Errorf("Expected Citizen title name, got '%s'", citizen.Name)
}
visitor, exists := mtl.GetTitle(TitleIDVisitor)
if !exists || visitor == nil {
t.Error("Expected Visitor title to be loaded by default")
}
newcomer, exists := mtl.GetTitle(TitleIDNewcomer)
if !exists || newcomer == nil {
t.Error("Expected Newcomer title to be loaded by default")
}
returning, exists := mtl.GetTitle(TitleIDReturning)
if !exists || returning == nil {
t.Error("Expected Returning title to be loaded by default")
}
// Test adding new title
testTitle := NewTitle(0, "Test Title") // ID will be assigned
testTitle.SetDescription("Test description")
testTitle.SetCategory(CategoryMiscellaneous)
err := mtl.AddTitle(testTitle)
if err != nil {
t.Fatalf("Failed to add title: %v", err)
}
// ID should have been assigned
if testTitle.ID == 0 {
t.Error("Title ID should have been assigned")
}
retrieved, exists := mtl.GetTitle(testTitle.ID)
if !exists || 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)
}
// Test duplicate ID
duplicateTitle := NewTitle(testTitle.ID, "Duplicate")
err = mtl.AddTitle(duplicateTitle)
if err == nil {
t.Error("Should have failed to add title with duplicate ID")
}
}
func TestMasterTitlesListCategories(t *testing.T) {
mtl := NewMasterTitlesList()
// Add titles in different categories
for i := 0; i < 3; i++ {
title := NewTitle(0, fmt.Sprintf("Achievement Title %d", i))
title.SetCategory(CategoryAchievement)
mtl.AddTitle(title)
}
for i := 0; i < 2; i++ {
title := NewTitle(0, fmt.Sprintf("Quest Title %d", i))
title.SetCategory(CategoryQuest)
mtl.AddTitle(title)
}
// Test getting titles by category
achievementTitles := mtl.GetTitlesByCategory(CategoryAchievement)
if len(achievementTitles) < 3 {
t.Errorf("Expected at least 3 achievement titles, got %d", len(achievementTitles))
}
questTitles := mtl.GetTitlesByCategory(CategoryQuest)
if len(questTitles) < 2 {
t.Errorf("Expected at least 2 quest titles, got %d", len(questTitles))
}
// Test getting available categories
categories := mtl.GetAvailableCategories()
hasAchievement := false
hasQuest := false
for _, cat := range categories {
if cat == CategoryAchievement {
hasAchievement = true
}
if cat == CategoryQuest {
hasQuest = true
}
}
if !hasAchievement {
t.Error("Expected CategoryAchievement in available categories")
}
if !hasQuest {
t.Error("Expected CategoryQuest in available categories")
}
}
func TestMasterTitlesListSourceAndRarity(t *testing.T) {
mtl := NewMasterTitlesList()
// Add titles with different sources
title1 := NewTitle(0, "Achievement Source")
title1.Source = TitleSourceAchievement
title1.SetRarity(TitleRarityCommon)
mtl.AddTitle(title1)
title2 := NewTitle(0, "Quest Source")
title2.Source = TitleSourceQuest
title2.SetRarity(TitleRarityRare)
mtl.AddTitle(title2)
title3 := NewTitle(0, "PvP Source")
title3.Source = TitleSourcePvP
title3.SetRarity(TitleRarityEpic)
mtl.AddTitle(title3)
// Test getting titles by source
achievementSourceTitles := mtl.GetTitlesBySource(TitleSourceAchievement)
found := false
for _, t := range achievementSourceTitles {
if t.Name == "Achievement Source" {
found = true
break
}
}
if !found {
t.Error("Failed to find achievement source title")
}
// Test getting titles by rarity
rareTitles := mtl.GetTitlesByRarity(TitleRarityRare)
found = false
for _, t := range rareTitles {
if t.Name == "Quest Source" {
found = true
break
}
}
if !found {
t.Error("Failed to find rare title")
}
}
// Test PlayerTitlesList
func TestPlayerTitlesList(t *testing.T) {
mtl := NewMasterTitlesList()
// Add some titles to master list
title1 := NewTitle(100, "Title One")
title1.Position = TitlePositionPrefix
mtl.AddTitle(title1)
title2 := NewTitle(101, "Title Two")
title2.Position = TitlePositionSuffix
mtl.AddTitle(title2)
ptl := NewPlayerTitlesList(123, mtl)
if ptl == nil {
t.Fatal("NewPlayerTitlesList returned nil")
}
// Test adding title
err := ptl.AddTitle(100, 0, 0)
if err != nil {
t.Fatalf("Failed to add title to player: %v", err)
}
err = ptl.AddTitle(101, 0, 0)
if err != nil {
t.Fatalf("Failed to add second title to player: %v", err)
}
// Test getting titles (player starts with default Citizen title)
titles := ptl.GetAllTitles()
expectedTitleCount := 3 // Citizen + our 2 titles
if len(titles) != expectedTitleCount {
t.Errorf("Expected %d titles, got %d", expectedTitleCount, len(titles))
}
// Test has title
if !ptl.HasTitle(100) {
t.Error("Expected player to have title 100")
}
if !ptl.HasTitle(101) {
t.Error("Expected player to have title 101")
}
if ptl.HasTitle(999) {
t.Error("Expected player not to have title 999")
}
// Test setting active prefix
err = ptl.SetActivePrefix(100)
if err != nil {
t.Fatalf("Failed to set active prefix: %v", err)
}
activePrefixTitle, hasPrefix := ptl.GetActivePrefixTitle()
if !hasPrefix || activePrefixTitle.ID != 100 {
t.Errorf("Expected active prefix 100, got title ID: %v", activePrefixTitle)
}
// Test setting active suffix
err = ptl.SetActiveSuffix(101)
if err != nil {
t.Fatalf("Failed to set active suffix: %v", err)
}
activeSuffixTitle, hasSuffix := ptl.GetActiveSuffixTitle()
if !hasSuffix || activeSuffixTitle.ID != 101 {
t.Errorf("Expected active suffix 101, got title ID: %v", activeSuffixTitle)
}
// Test formatted name (the actual format may vary)
formattedName := ptl.GetFormattedName("PlayerName")
// Just check that it contains both titles and the player name
if !contains(formattedName, "Title One") {
t.Errorf("Formatted name should contain 'Title One', got '%s'", formattedName)
}
if !contains(formattedName, "Title Two") {
t.Errorf("Formatted name should contain 'Title Two', got '%s'", formattedName)
}
if !contains(formattedName, "PlayerName") {
t.Errorf("Formatted name should contain 'PlayerName', got '%s'", formattedName)
}
// Test removing title
err = ptl.RemoveTitle(100)
if err != nil {
t.Fatalf("Failed to remove title: %v", err)
}
if ptl.HasTitle(100) {
t.Error("Title 100 should have been removed")
}
// Active prefix should be cleared
_, hasPrefix = ptl.GetActivePrefixTitle()
if hasPrefix {
t.Error("Active prefix should be cleared after removing the title")
}
}
func TestPlayerTitlesExpiration(t *testing.T) {
mtl := NewMasterTitlesList()
// Add a temporary title to master list
tempTitle := NewTitle(200, "Temporary Title")
tempTitle.SetFlag(FlagTemporary)
tempTitle.ExpirationHours = 1 // Expires after 1 hour
mtl.AddTitle(tempTitle)
ptl := NewPlayerTitlesList(456, mtl)
// Add the temporary title
err := ptl.AddTitle(200, 0, 0)
if err != nil {
t.Fatalf("Failed to add temporary title: %v", err)
}
// Title should exist
if !ptl.HasTitle(200) {
t.Error("Expected player to have temporary title")
}
// Manually set expiration to past
if playerTitle, exists := ptl.titles[200]; exists {
playerTitle.ExpiresAt = time.Now().Add(-1 * time.Hour)
}
// Clean up expired titles
expired := ptl.CleanupExpiredTitles()
if expired != 1 {
t.Errorf("Expected 1 expired title, got %d", expired)
}
// Title should be removed
if ptl.HasTitle(200) {
t.Error("Expired title should have been removed")
}
}
// Test TitleManager
func TestTitleManager(t *testing.T) {
tm := NewTitleManager()
if tm == nil {
t.Fatal("NewTitleManager returned nil")
}
defer tm.Shutdown()
// Test getting player titles
playerTitles := tm.GetPlayerTitles(456)
if playerTitles == nil {
t.Error("GetPlayerTitles returned nil")
}
// Test granting title (use Visitor instead of Citizen to avoid conflicts)
err := tm.GrantTitle(456, TitleIDVisitor, 0, 0)
if err != nil {
t.Fatalf("Failed to grant title: %v", err)
}
// Verify title was granted (player starts with default Citizen title)
playerTitles = tm.GetPlayerTitles(456)
titles := playerTitles.GetAllTitles()
expectedCount := 2 // Citizen + Visitor
if len(titles) != expectedCount {
t.Errorf("Expected %d titles for player, got %d", expectedCount, len(titles))
}
// Verify the player has the visitor title
if !playerTitles.HasTitle(TitleIDVisitor) {
t.Error("Player should have Visitor title")
}
// Test revoking title
err = tm.RevokeTitle(456, TitleIDVisitor)
if err != nil {
t.Fatalf("Failed to revoke title: %v", err)
}
// Verify title was revoked (should still have Citizen)
playerTitles = tm.GetPlayerTitles(456)
titles = playerTitles.GetAllTitles()
if len(titles) != 1 {
t.Errorf("Expected 1 title after revoke, got %d", len(titles))
}
}
func TestTitleManagerCreateVariants(t *testing.T) {
tm := NewTitleManager()
defer tm.Shutdown()
// Test creating regular title
title, err := tm.CreateTitle("Regular Title", "A regular title", CategoryMiscellaneous, TitlePositionSuffix, TitleSourceQuest, TitleRarityCommon)
if err != nil {
t.Fatalf("Failed to create regular title: %v", err)
}
if title == nil {
t.Fatal("CreateTitle returned nil")
}
// Test creating achievement title
achievementTitle, err := tm.CreateAchievementTitle("Achievement Title", "An achievement title", 1000, TitlePositionPrefix, TitleRarityRare)
if err != nil {
t.Fatalf("Failed to create achievement title: %v", err)
}
if achievementTitle.AchievementID != 1000 {
t.Errorf("Expected achievement ID 1000, got %d", achievementTitle.AchievementID)
}
// Test creating temporary title
tempTitle, err := tm.CreateTemporaryTitle("Temp Title", "A temporary title", 24, TitlePositionSuffix, TitleSourceHoliday, TitleRarityCommon)
if err != nil {
t.Fatalf("Failed to create temporary title: %v", err)
}
if !tempTitle.HasFlag(FlagTemporary) {
t.Error("Temporary title should have FlagTemporary")
}
if tempTitle.ExpirationHours != 24 {
t.Errorf("Expected expiration hours 24, got %d", tempTitle.ExpirationHours)
}
// Test creating unique title
uniqueTitle, err := tm.CreateUniqueTitle("Unique Title", "A unique title", TitlePositionPrefix, TitleSourceMiscellaneous)
if err != nil {
t.Fatalf("Failed to create unique title: %v", err)
}
if !uniqueTitle.HasFlag(FlagUnique) {
t.Error("Unique title should have FlagUnique")
}
if uniqueTitle.Rarity != TitleRarityUnique {
t.Errorf("Expected rarity %d for unique title, got %d", TitleRarityUnique, uniqueTitle.Rarity)
}
}
func TestTitleManagerSearch(t *testing.T) {
tm := NewTitleManager()
defer tm.Shutdown()
// Create test titles
tm.CreateTitle("Dragon Slayer", "Defeated a dragon", CategoryCombat, TitlePositionSuffix, TitleSourceQuest, TitleRarityEpic)
tm.CreateTitle("Master Crafter", "Master of crafting", CategoryTradeskill, TitlePositionPrefix, TitleSourceTradeskill, TitleRarityRare)
tm.CreateTitle("Explorer", "Explored the world", CategoryExploration, TitlePositionSuffix, TitleSourceAchievement, TitleRarityCommon)
// Test search
results := tm.SearchTitles("dragon")
found := false
for _, title := range results {
if title.Name == "Dragon Slayer" {
found = true
break
}
}
if !found {
t.Error("Failed to find 'Dragon Slayer' in search results")
}
// Test search with description
results = tm.SearchTitles("crafting")
found = false
for _, title := range results {
if title.Name == "Master Crafter" {
found = true
break
}
}
if !found {
t.Error("Failed to find 'Master Crafter' when searching description")
}
}
func TestTitleManagerStatistics(t *testing.T) {
tm := NewTitleManager()
defer tm.Shutdown()
// Grant some titles (avoid Citizen which may be default)
tm.GrantTitle(1, TitleIDVisitor, 0, 0)
tm.GrantTitle(2, TitleIDNewcomer, 0, 0)
tm.GrantTitle(1, TitleIDReturning, 0, 0)
stats := tm.GetStatistics()
// Check statistics
if totalPlayers, ok := stats["total_players"].(int); ok {
if totalPlayers != 2 {
t.Errorf("Expected 2 total players, got %d", totalPlayers)
}
} else {
t.Error("Missing total_players in statistics")
}
if titlesGranted, ok := stats["titles_granted"].(int64); ok {
if titlesGranted != 3 {
t.Errorf("Expected 3 titles granted, got %d", titlesGranted)
}
} else {
t.Error("Missing titles_granted in statistics")
}
}
func TestTitleManagerConcurrency(t *testing.T) {
tm := NewTitleManager()
defer tm.Shutdown()
// Test concurrent access to player titles
var wg sync.WaitGroup
numPlayers := 10
numOperations := 100
for i := 0; i < numPlayers; i++ {
wg.Add(1)
go func(playerID int32) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
// Grant title
tm.GrantTitle(playerID, TitleIDCitizen, 0, 0)
// Get player titles
ptl := tm.GetPlayerTitles(playerID)
_ = ptl.GetAllTitles()
// Set active title
tm.SetPlayerActivePrefix(playerID, TitleIDCitizen)
// Get formatted name
tm.GetPlayerFormattedName(playerID, fmt.Sprintf("Player%d", playerID))
// Revoke title
tm.RevokeTitle(playerID, TitleIDCitizen)
}
}(int32(i))
}
wg.Wait()
// Verify no crashes or data races
stats := tm.GetStatistics()
if stats == nil {
t.Error("Failed to get statistics after concurrent operations")
}
}
// Test Database Integration
func TestDatabaseIntegration(t *testing.T) {
// Skip this test as it requires a MySQL database connection
t.Skip("Skipping database integration test - requires MySQL database connection")
// Create temporary database
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_titles.db")
db, err := OpenDB(dbPath)
if err != nil {
t.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
// Create tables
err = db.CreateTables()
if err != nil {
t.Fatalf("Failed to create tables: %v", err)
}
// Test master list database operations
mtl := NewMasterTitlesList()
// Add some test titles
title1 := NewTitle(500, "Database Test Title 1")
title1.SetDescription("Test description 1")
title1.SetCategory(CategoryMiscellaneous)
title1.Position = TitlePositionPrefix // Set as prefix title
mtl.AddTitle(title1)
title2 := NewTitle(501, "Database Test Title 2")
title2.SetDescription("Test description 2")
title2.SetCategory(CategoryAchievement)
title2.Position = TitlePositionSuffix // Set as suffix title
title2.AchievementID = 2000
mtl.AddTitle(title2)
// Save 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 loaded titles
loadedTitle1, exists := mtl2.GetTitle(500)
if !exists || loadedTitle1 == nil {
t.Fatal("Failed to load title 500 from database")
}
if loadedTitle1.Name != "Database Test Title 1" {
t.Errorf("Expected title name 'Database Test Title 1', got '%s'", loadedTitle1.Name)
}
loadedTitle2, exists := mtl2.GetTitle(501)
if !exists || loadedTitle2 == nil {
t.Fatal("Failed to load title 501 from database")
}
if loadedTitle2.AchievementID != 2000 {
t.Errorf("Expected achievement ID 2000, got %d", loadedTitle2.AchievementID)
}
// Test player titles database operations
ptl := NewPlayerTitlesList(789, mtl2) // Use mtl2 which has titles loaded from database
ptl.AddTitle(500, 0, 0)
ptl.AddTitle(501, 2000, 0)
ptl.SetActivePrefix(500)
ptl.SetActiveSuffix(501)
// 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, mtl2)
err = ptl2.LoadFromDatabase(db)
if err != nil {
t.Fatalf("Failed to load player titles from database: %v", err)
}
// Verify loaded player titles
if !ptl2.HasTitle(500) {
t.Error("Expected player to have title 500")
}
if !ptl2.HasTitle(501) {
t.Error("Expected player to have title 501")
}
activePrefixTitle, hasPrefix := ptl2.GetActivePrefixTitle()
if !hasPrefix {
t.Error("Expected to have active prefix title")
} else if activePrefixTitle.ID != 500 {
t.Errorf("Expected active prefix 500, got %d", activePrefixTitle.ID)
}
activeSuffixTitle, hasSuffix := ptl2.GetActiveSuffixTitle()
if !hasSuffix {
t.Error("Expected to have active suffix title")
} else if activeSuffixTitle.ID != 501 {
t.Errorf("Expected active suffix 501, got %d", activeSuffixTitle.ID)
}
}
func TestDatabaseHelperFunctions(t *testing.T) {
// Test nullableUint32
if val := nullableUint32(0); val != nil {
t.Error("nullableUint32(0) should return nil")
}
if val := nullableUint32(123); val != uint32(123) {
t.Errorf("nullableUint32(123) should return 123, got %v", val)
}
// Test nullableTime
zeroTime := time.Time{}
if val := nullableTime(zeroTime); val != nil {
t.Error("nullableTime(zero) should return nil")
}
now := time.Now()
if val := nullableTime(now); val != now.Unix() {
t.Errorf("nullableTime(now) should return Unix timestamp, got %v", val)
}
}
// Benchmark tests
func BenchmarkTitleCreation(b *testing.B) {
for i := 0; i < b.N; i++ {
title := NewTitle(int32(i), fmt.Sprintf("Title %d", i))
title.SetDescription("Description")
title.SetCategory(CategoryMiscellaneous)
}
}
func BenchmarkMasterListAdd(b *testing.B) {
mtl := NewMasterTitlesList()
b.ResetTimer()
for i := 0; i < b.N; i++ {
title := NewTitle(int32(i+1000), fmt.Sprintf("Title %d", i))
mtl.AddTitle(title)
}
}
func BenchmarkPlayerListAdd(b *testing.B) {
mtl := NewMasterTitlesList()
// Pre-populate master list
for i := 0; i < 1000; i++ {
title := NewTitle(int32(i), fmt.Sprintf("Title %d", i))
mtl.AddTitle(title)
}
ptl := NewPlayerTitlesList(1, mtl)
b.ResetTimer()
for i := 0; i < b.N; i++ {
titleID := int32(i % 1000)
ptl.AddTitle(titleID, 0, 0)
ptl.RemoveTitle(titleID)
}
}
func BenchmarkTitleManagerGrant(b *testing.B) {
tm := NewTitleManager()
defer tm.Shutdown()
// Pre-create titles
for i := 0; i < 100; i++ {
tm.CreateTitle(fmt.Sprintf("Title %d", i), "Description", CategoryMiscellaneous, TitlePositionSuffix, TitleSourceQuest, TitleRarityCommon)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
playerID := int32(i % 100)
titleID := int32((i % 100) + 1)
tm.GrantTitle(playerID, titleID, 0, 0)
}
}
func BenchmarkTitleSearch(b *testing.B) {
tm := NewTitleManager()
defer tm.Shutdown()
// Create fewer titles for faster benchmark
for i := 0; i < 100; i++ {
name := fmt.Sprintf("Title %d", i)
if i%10 == 0 {
name = fmt.Sprintf("Dragon %d", i)
}
tm.CreateTitle(name, "Description", CategoryMiscellaneous, TitlePositionSuffix, TitleSourceQuest, TitleRarityCommon)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
tm.SearchTitles("dragon")
}
}
func BenchmarkConcurrentAccess(b *testing.B) {
tm := NewTitleManager()
defer tm.Shutdown()
// Pre-populate with titles
for i := 0; i < 10; i++ {
tm.CreateTitle(fmt.Sprintf("Title %d", i), "Description", CategoryMiscellaneous, TitlePositionSuffix, TitleSourceQuest, TitleRarityCommon)
}
b.ResetTimer()
// Use simpler sequential approach instead of RunParallel to avoid deadlocks
for i := 0; i < b.N; i++ {
playerID := int32(i % 10)
titleID := int32((i % 10) + 1)
tm.GrantTitle(playerID, titleID, 0, 0)
tm.GetPlayerFormattedName(playerID, "Player")
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,274 +0,0 @@
# Tradeskills System
The tradeskills system provides complete crafting functionality for the EQ2 server. It has been fully converted from the original C++ EQ2EMu implementation to Go.
## Overview
The tradeskills system handles:
- **Crafting Sessions**: Player crafting with progress/durability mechanics
- **Tradeskill Events**: Random events requiring player counter-actions
- **Recipe Management**: Component validation and product creation
- **Progress Stages**: Multiple completion stages with different rewards
- **Experience System**: Tradeskill XP and level progression
- **Animation System**: Client-specific animations for different techniques
## Core Components
### Files
- `constants.go` - Animation IDs, technique constants, and configuration values
- `types.go` - Core data structures (TradeskillEvent, Tradeskill, TradeskillManager, etc.)
- `manager.go` - Main TradeskillManager implementation with crafting logic
- `database.go` - Database operations for tradeskill events persistence
- `packets.go` - Packet building for crafting UI and updates
- `interfaces.go` - Integration interfaces and TradeskillSystemAdapter
- `README.md` - This documentation
### Main Types
- `TradeskillEvent` - Events that occur during crafting requiring counter-actions
- `Tradeskill` - Individual crafting session with progress tracking
- `TradeskillManager` - Central management of all active crafting sessions
- `MasterTradeskillEventsList` - Registry of all available tradeskill events
- `TradeskillSystemAdapter` - High-level integration with other game systems
## Tradeskill Techniques
The system supports all EQ2 tradeskill techniques:
1. **Alchemy** (510901001) - Potion and reagent creation
2. **Tailoring** (510901002) - Cloth and leather armor
3. **Fletching** (510901003) - Arrows and ranged weapons
4. **Jewelcrafting** (510901004) - Jewelry and accessories
5. **Provisioning** (510901005) - Food and drink
6. **Scribing** (510901007) - Spells and scrolls
7. **Transmuting** (510901008) - Material conversion
8. **Artistry** (510901009) - Decorative items
9. **Carpentry** (510901010) - Wooden items and furniture
10. **Metalworking** (510901011) - Metal weapons and tools
11. **Metalshaping** (510901012) - Metal armor and shields
12. **Stoneworking** (510901013) - Stone items and structures
## Crafting Process
### Session Lifecycle
1. **Validation**: Recipe, components, and crafting table validation
2. **Setup**: Lock inventory items, send UI packets, start session
3. **Processing**: Periodic updates every 4 seconds with progress/durability changes
4. **Events**: Random events with counter opportunities
5. **Completion**: Reward calculation, XP award, cleanup
### Outcome Types
- **Critical Success** (1%): +100 progress, +10 durability
- **Success** (87%): +50 progress, -10 durability
- **Failure** (10%): 0 progress, -50 durability
- **Critical Failure** (2%): -50 progress, -100 durability
### Progress Stages
- **Stage 0**: 0-399 progress (fuel/byproduct)
- **Stage 1**: 400-599 progress (basic product)
- **Stage 2**: 600-799 progress (improved product)
- **Stage 3**: 800-999 progress (high-quality product)
- **Stage 4**: 1000 progress (masterwork product)
## Usage
### Basic Setup
```go
// Create manager and events list
manager := tradeskills.NewTradeskillManager()
eventsList := tradeskills.NewMasterTradeskillEventsList()
// Create database service
db, _ := database.Open("tradeskills.db")
dbService := tradeskills.NewSQLiteTradeskillDatabase(db)
// Load events from database
dbService.LoadTradeskillEvents(eventsList)
// Create system adapter with all dependencies
adapter := tradeskills.NewTradeskillSystemAdapter(
manager, eventsList, dbService, packetBuilder,
playerManager, itemManager, recipeManager, spellManager,
zoneManager, experienceManager, questManager, ruleManager,
)
// Initialize the system
adapter.Initialize()
```
### Starting Crafting
```go
// Define components to use
components := []tradeskills.ComponentUsage{
{ItemUniqueID: 12345, Quantity: 2}, // Primary component
{ItemUniqueID: 67890, Quantity: 1}, // Fuel component
}
// Start crafting session
err := adapter.StartCrafting(playerID, recipeID, components)
if err != nil {
log.Printf("Failed to start crafting: %v", err)
}
```
### Processing Updates
```go
// Run periodic updates (typically every 50ms)
ticker := time.NewTicker(50 * time.Millisecond)
go func() {
for range ticker.C {
adapter.ProcessCraftingUpdates()
}
}()
```
### Handling Events
```go
// Player attempts to counter an event
err := adapter.HandleEventCounter(playerID, spellIcon)
if err != nil {
log.Printf("Failed to handle event counter: %v", err)
}
```
## Database Schema
### tradeskillevents Table
```sql
CREATE TABLE tradeskillevents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
icon INTEGER NOT NULL,
technique INTEGER NOT NULL,
success_progress INTEGER NOT NULL DEFAULT 0,
success_durability INTEGER NOT NULL DEFAULT 0,
success_hp INTEGER NOT NULL DEFAULT 0,
success_power INTEGER NOT NULL DEFAULT 0,
success_spell_id INTEGER NOT NULL DEFAULT 0,
success_item_id INTEGER NOT NULL DEFAULT 0,
fail_progress INTEGER NOT NULL DEFAULT 0,
fail_durability INTEGER NOT NULL DEFAULT 0,
fail_hp INTEGER NOT NULL DEFAULT 0,
fail_power INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(name, technique)
);
```
## Configuration
The system uses configurable rules for outcome chances:
- **Success Rate**: 87% (configurable via rules)
- **Critical Success Rate**: 2% (configurable via rules)
- **Failure Rate**: 10% (configurable via rules)
- **Critical Failure Rate**: 1% (configurable via rules)
- **Event Chance**: 30% (configurable via rules)
## Client Animations
The system provides client-version-specific animations:
### Animation Types
- **Success**: Played on successful crafting outcomes
- **Failure**: Played on failed crafting outcomes
- **Idle**: Played during active crafting
- **Miss Target**: Played on targeting errors
- **Kill Miss Target**: Played on critical targeting errors
### Version Support
- **Version ≤ 561**: Legacy animation IDs
- **Version > 561**: Modern animation IDs
## Integration Interfaces
The system integrates with other game systems through well-defined interfaces:
- `PlayerManager` - Player operations and messaging
- `ItemManager` - Inventory and item operations
- `RecipeManager` - Recipe validation and tracking
- `SpellManager` - Tradeskill spell management
- `ZoneManager` - Spawn and animation operations
- `ExperienceManager` - XP calculation and awards
- `QuestManager` - Quest update integration
- `RuleManager` - Configuration and rules access
## Thread Safety
All operations are thread-safe using Go's sync.RWMutex for optimal read performance during frequent access patterns.
## Performance
- Crafting updates: ~1ms per active session
- Event processing: ~500μs per event
- Database operations: Optimized with proper indexing
- Memory usage: ~1KB per active crafting session
## Testing
Run the test suite:
```bash
go test ./internal/tradeskills/ -v
```
## Migration from C++
This is a complete conversion from the original C++ implementation:
- `Tradeskills.h``constants.go` + `types.go`
- `Tradeskills.cpp``manager.go`
- `TradeskillsDB.cpp``database.go`
- `TradeskillsPackets.cpp``packets.go`
All functionality has been preserved with Go-native patterns and improvements:
- Better error handling and logging
- Type safety with strongly-typed interfaces
- Comprehensive integration system
- Modern testing practices
- Performance optimizations
- Thread-safe concurrent access
## Key Features
### Crafting Mechanics
- Real-time progress and durability tracking
- Skill-based success/failure calculations
- Component consumption and validation
- Multi-stage completion system
### Event System
- Random event generation during crafting
- Player counter-action requirements
- Success/failure rewards and penalties
- Icon-based event identification
### Animation System
- Technique-specific animations
- Client version compatibility
- Success/failure/idle animations
- Visual state management
### Experience System
- Recipe level-based XP calculation
- Stage-based XP multipliers
- Tradeskill level progression
- Quest integration for crafting updates
### UI Integration
- Complex recipe selection UI
- Real-time crafting progress UI
- Component selection and validation
- Mass production support
The tradeskills system provides a complete, production-ready crafting implementation that maintains full compatibility with the original EQ2 client while offering modern Go development practices.

View File

@ -1,428 +0,0 @@
package tradeskills
import (
"testing"
"time"
)
func TestTradeskillEvent(t *testing.T) {
event := &TradeskillEvent{
Name: "Test Event",
Icon: 1234,
Technique: TechniqueSkillAlchemy,
SuccessProgress: 25,
SuccessDurability: 0,
SuccessHP: 0,
SuccessPower: -10,
SuccessSpellID: 0,
SuccessItemID: 0,
FailProgress: -10,
FailDurability: -25,
FailHP: 0,
FailPower: 0,
}
// Test Copy method
copied := event.Copy()
if copied == nil {
t.Fatal("Copy returned nil")
}
if copied.Name != event.Name {
t.Errorf("Expected name %s, got %s", event.Name, copied.Name)
}
if copied.Technique != event.Technique {
t.Errorf("Expected technique %d, got %d", event.Technique, copied.Technique)
}
// Test Copy with nil
var nilEvent *TradeskillEvent
copiedNil := nilEvent.Copy()
if copiedNil != nil {
t.Error("Copy of nil should return nil")
}
}
func TestTradeskillManager(t *testing.T) {
manager := NewTradeskillManager()
if manager == nil {
t.Fatal("NewTradeskillManager returned nil")
}
// Test initial state
if manager.IsClientCrafting(12345) {
t.Error("New manager should not have any active crafting sessions")
}
// Test begin crafting
request := CraftingRequest{
PlayerID: 12345,
RecipeID: 67890,
TableSpawnID: 11111,
Components: []ComponentUsage{
{ItemUniqueID: 22222, Quantity: 2},
{ItemUniqueID: 33333, Quantity: 1},
},
Quantity: 1,
}
err := manager.BeginCrafting(request)
if err != nil {
t.Fatalf("BeginCrafting failed: %v", err)
}
// Test client is now crafting
if !manager.IsClientCrafting(12345) {
t.Error("Client should be crafting after BeginCrafting")
}
// Test get tradeskill
tradeskill := manager.GetTradeskill(12345)
if tradeskill == nil {
t.Fatal("GetTradeskill returned nil")
}
if tradeskill.PlayerID != 12345 {
t.Errorf("Expected player ID 12345, got %d", tradeskill.PlayerID)
}
if tradeskill.RecipeID != 67890 {
t.Errorf("Expected recipe ID 67890, got %d", tradeskill.RecipeID)
}
// Test stop crafting
err = manager.StopCrafting(12345)
if err != nil {
t.Fatalf("StopCrafting failed: %v", err)
}
// Test client is no longer crafting
if manager.IsClientCrafting(12345) {
t.Error("Client should not be crafting after StopCrafting")
}
}
func TestTradeskillSession(t *testing.T) {
now := time.Now()
tradeskill := &Tradeskill{
PlayerID: 12345,
TableSpawnID: 11111,
RecipeID: 67890,
CurrentProgress: 500,
CurrentDurability: 800,
NextUpdateTime: now.Add(time.Second),
UsedComponents: []ComponentUsage{
{ItemUniqueID: 22222, Quantity: 2},
},
StartTime: now,
LastUpdate: now,
}
// Test completion check
if tradeskill.IsComplete() {
t.Error("Tradeskill with 500 progress should not be complete")
}
tradeskill.CurrentProgress = MaxProgress
if !tradeskill.IsComplete() {
t.Error("Tradeskill with max progress should be complete")
}
// Test failure check
if tradeskill.IsFailed() {
t.Error("Tradeskill with 800 durability should not be failed")
}
tradeskill.CurrentDurability = MinDurability
if !tradeskill.IsFailed() {
t.Error("Tradeskill with min durability should be failed")
}
// Test update check
tradeskill.NextUpdateTime = now.Add(-time.Second) // Past time
if !tradeskill.NeedsUpdate() {
t.Error("Tradeskill with past update time should need update")
}
// Test reset
tradeskill.Reset()
if tradeskill.CurrentProgress != MinProgress {
t.Errorf("Expected progress %d after reset, got %d", MinProgress, tradeskill.CurrentProgress)
}
if tradeskill.CurrentDurability != MaxDurability {
t.Errorf("Expected durability %d after reset, got %d", MaxDurability, tradeskill.CurrentDurability)
}
}
func TestMasterTradeskillEventsList(t *testing.T) {
eventsList := NewMasterTradeskillEventsList()
if eventsList == nil {
t.Fatal("NewMasterTradeskillEventsList returned nil")
}
// Test initial state
if eventsList.Size() != 0 {
t.Error("New events list should be empty")
}
// Test add event
event := &TradeskillEvent{
Name: "Test Event",
Icon: 1234,
Technique: TechniqueSkillAlchemy,
}
eventsList.AddEvent(event)
if eventsList.Size() != 1 {
t.Errorf("Expected size 1 after adding event, got %d", eventsList.Size())
}
// Test get by technique
events := eventsList.GetEventByTechnique(TechniqueSkillAlchemy)
if len(events) != 1 {
t.Errorf("Expected 1 event for alchemy, got %d", len(events))
}
if events[0].Name != "Test Event" {
t.Errorf("Expected event name 'Test Event', got %s", events[0].Name)
}
// Test get by non-existent technique
noEvents := eventsList.GetEventByTechnique(TechniqueSkillFletching)
if len(noEvents) != 0 {
t.Errorf("Expected 0 events for fletching, got %d", len(noEvents))
}
// Test add nil event
eventsList.AddEvent(nil)
if eventsList.Size() != 1 {
t.Error("Adding nil event should not change size")
}
// Test clear
eventsList.Clear()
if eventsList.Size() != 0 {
t.Error("Events list should be empty after Clear")
}
}
func TestValidTechnique(t *testing.T) {
validTechniques := []uint32{
TechniqueSkillAlchemy,
TechniqueSkillTailoring,
TechniqueSkillFletching,
TechniqueSkillJewelcrafting,
TechniqueSkillProvisioning,
TechniqueSkillScribing,
TechniqueSkillTransmuting,
TechniqueSkillArtistry,
TechniqueSkillCarpentry,
TechniqueSkillMetalworking,
TechniqueSkillMetalshaping,
TechniqueSkillStoneworking,
}
for _, technique := range validTechniques {
if !IsValidTechnique(technique) {
t.Errorf("Technique %d should be valid", technique)
}
}
// Test invalid technique
if IsValidTechnique(999999) {
t.Error("Invalid technique should not be valid")
}
}
func TestDatabaseOperations(t *testing.T) {
t.Skip("Database operations require actual database connection - skipping for basic validation")
// This test would work with a real database connection
// For now, just test that the interface methods exist and compile
// Mock database service
var dbService DatabaseService
_ = dbService // Ensure interface compiles
}
func TestAnimationMethods(t *testing.T) {
manager := NewTradeskillManager()
testCases := []struct {
technique uint32
version int16
expectNonZero bool
}{
{TechniqueSkillAlchemy, 500, true},
{TechniqueSkillAlchemy, 1000, true},
{TechniqueSkillTailoring, 500, true},
{TechniqueSkillFletching, 1000, true},
{TechniqueSkillScribing, 500, false}, // No animations for scribing
{999999, 500, false}, // Invalid technique
}
for _, tc := range testCases {
successAnim := manager.GetTechniqueSuccessAnim(tc.version, tc.technique)
failureAnim := manager.GetTechniqueFailureAnim(tc.version, tc.technique)
idleAnim := manager.GetTechniqueIdleAnim(tc.version, tc.technique)
if tc.expectNonZero {
if successAnim == 0 {
t.Errorf("Expected non-zero success animation for technique %d, version %d", tc.technique, tc.version)
}
if failureAnim == 0 {
t.Errorf("Expected non-zero failure animation for technique %d, version %d", tc.technique, tc.version)
}
if idleAnim == 0 {
t.Errorf("Expected non-zero idle animation for technique %d, version %d", tc.technique, tc.version)
}
}
}
// Test miss target animations
missAnim := manager.GetMissTargetAnim(500)
if missAnim == 0 {
t.Error("Expected non-zero miss target animation for version 500")
}
killMissAnim := manager.GetKillMissTargetAnim(1000)
if killMissAnim == 0 {
t.Error("Expected non-zero kill miss target animation for version 1000")
}
}
func TestConfigurationUpdate(t *testing.T) {
manager := NewTradeskillManager()
// Test valid configuration
err := manager.UpdateConfiguration(1.0, 2.0, 10.0, 87.0, 30.0)
if err != nil {
t.Errorf("Valid configuration update failed: %v", err)
}
// Test invalid configuration (doesn't add to 100%)
err = manager.UpdateConfiguration(1.0, 2.0, 10.0, 80.0, 30.0) // Only adds to 93%
if err != nil {
t.Errorf("Invalid configuration should not return error, should use defaults: %v", err)
}
}
func TestPacketHelper(t *testing.T) {
helper := &PacketHelper{}
// Test progress stage calculation
testCases := []struct {
progress int32
expected int8
}{
{0, 0},
{300, 0},
{400, 1},
{550, 1},
{600, 2},
{750, 2},
{800, 3},
{950, 3},
{1000, 4},
{1100, 4}, // Clamped to max
}
for _, tc := range testCases {
result := helper.CalculateProgressStage(tc.progress)
if result != tc.expected {
t.Errorf("Progress %d: expected stage %d, got %d", tc.progress, tc.expected, result)
}
}
// Test mass production quantities
quantities := helper.GetMassProductionQuantities(3)
if len(quantities) == 0 {
t.Error("Should return at least base quantity")
}
if quantities[0] != 1 {
t.Error("First quantity should always be 1")
}
// Test component validation
components := []ComponentUsage{
{ItemUniqueID: 12345, Quantity: 2},
{ItemUniqueID: 67890, Quantity: 1},
}
err := helper.ValidateRecipeComponents(12345, components)
if err != nil {
t.Errorf("Valid components should pass validation: %v", err)
}
// Test invalid components
invalidComponents := []ComponentUsage{
{ItemUniqueID: 0, Quantity: 2}, // Invalid unique ID
}
err = helper.ValidateRecipeComponents(12345, invalidComponents)
if err == nil {
t.Error("Invalid components should fail validation")
}
// Test packet type calculation
packetType := helper.CalculateItemPacketType(500)
if packetType == 0 {
t.Error("Should return non-zero packet type")
}
// Test value clamping
clampedProgress := helper.ClampProgress(-100)
if clampedProgress != MinProgress {
t.Errorf("Expected clamped progress %d, got %d", MinProgress, clampedProgress)
}
clampedDurability := helper.ClampDurability(2000)
if clampedDurability != MaxDurability {
t.Errorf("Expected clamped durability %d, got %d", MaxDurability, clampedDurability)
}
}
func BenchmarkTradeskillManagerProcess(b *testing.B) {
manager := NewTradeskillManager()
// Add some test sessions
for i := 0; i < 10; i++ {
request := CraftingRequest{
PlayerID: uint32(i + 1),
RecipeID: 67890,
TableSpawnID: 11111,
Components: []ComponentUsage{
{ItemUniqueID: 22222, Quantity: 2},
},
Quantity: 1,
}
manager.BeginCrafting(request)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
manager.Process()
}
}
func BenchmarkEventListAccess(b *testing.B) {
eventsList := NewMasterTradeskillEventsList()
// Add test events
for i := 0; i < 100; i++ {
event := &TradeskillEvent{
Name: "Test Event",
Icon: int16(i),
Technique: TechniqueSkillAlchemy,
}
eventsList.AddEvent(event)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
eventsList.GetEventByTechnique(TechniqueSkillAlchemy)
}
}

View File

@ -1,338 +0,0 @@
# Traits System
The traits system provides character advancement through selectable abilities and focuses. It has been fully converted from the original C++ EQ2EMu implementation to Go.
## Overview
The traits system handles:
- **Character Traits**: Universal abilities available to all classes and races
- **Class Training**: Specialized abilities specific to each class
- **Racial Traditions**: Abilities specific to each race
- **Innate Racial Abilities**: Passive racial abilities
- **Focus Effects**: Advanced abilities available at higher levels
- **Tiered Selection**: Progressive trait selection based on player choices
## Core Components
### Files
- `constants.go` - Trait categories, level requirements, and configuration constants
- `types.go` - Core data structures (TraitData, MasterTraitList, PlayerTraitState, etc.)
- `manager.go` - Main trait management with selection logic and validation
- `packets.go` - Packet building for trait UI and selection
- `interfaces.go` - Integration interfaces and TraitSystemAdapter
- `traits_test.go` - Comprehensive test coverage
- `README.md` - This documentation
### Main Types
- `TraitData` - Individual trait definition with requirements and properties
- `MasterTraitList` - Registry of all available traits in the system
- `PlayerTraitState` - Individual player's trait selections and state
- `TraitManager` - High-level trait operations and player state management
- `TraitSystemAdapter` - Complete integration with other game systems
## Trait Categories
The system supports six trait categories:
1. **Attributes** (0) - Attribute-based traits (STR, STA, etc.)
2. **Combat** (1) - Combat-related traits and abilities
3. **Noncombat** (2) - Non-combat utility traits
4. **Pools** (3) - Health/Power/Concentration pool traits
5. **Resist** (4) - Resistance-based traits
6. **Tradeskill** (5) - Tradeskill-related traits
## Trait Types
### Character Traits
- Available to all classes and races (ClassReq=255, RaceReq=255)
- Selectable based on character level progression
- Organized by group and level for UI display
### Class Training
- Specific to each adventure class
- Available at specific levels based on class progression
- Enhances class-specific abilities
### Racial Traditions
- Specific to each race
- Both active abilities and passive bonuses
- Reflects racial heritage and culture
### Innate Racial Abilities
- Passive abilities automatically granted by race
- Cannot be unselected once granted
- Represent inherent racial characteristics
### Focus Effects
- Advanced abilities available at higher levels
- Require specialized knowledge and experience
- Often provide significant combat or utility benefits
## Classic Trait Progression
The system implements the classic EverQuest II trait progression schedule:
```
Level 8: Personal Trait (1st)
Level 10: Training (1st)
Level 12: Enemy Tactic (1st)
Level 14: Personal Trait (2nd)
Level 16: Enemy Tactic (2nd)
Level 18: Racial Tradition (1st)
Level 20: Training (2nd)
Level 22: Personal Trait (3rd)
Level 24: Enemy Tactic (3rd)
Level 26: Racial Tradition (2nd)
Level 28: Personal Trait (4th)
Level 30: Training (3rd)
Level 32: Enemy Tactic (4th)
Level 34: Racial Tradition (3rd)
Level 36: Personal Trait (5th)
Level 38: Enemy Tactic (5th)
Level 40: Training (4th)
Level 42: Personal Trait (6th)
Level 44: Racial Tradition (4th)
Level 46: Personal Trait (7th)
Level 48: Personal Trait (8th)
Level 50: Training (5th)
```
### Level Requirements
- **Personal Traits**: Levels 8, 14, 22, 28, 36, 42, 46, 48
- **Training**: Levels 10, 20, 30, 40, 50
- **Racial Traditions**: Levels 18, 26, 34, 44
- **Enemy Tactics**: Levels 12, 16, 24, 32, 38
## Usage
### Basic Setup
```go
// Create master trait list and manager
masterList := traits.NewMasterTraitList()
config := &traits.TraitSystemConfig{
TieringSelection: true,
UseClassicLevelTable: true,
FocusSelectLevel: 9,
TrainingSelectLevel: 10,
RaceSelectLevel: 10,
CharacterSelectLevel: 4,
}
manager := traits.NewTraitManager(masterList, config)
// Create system adapter
adapter := traits.NewTraitSystemAdapter(
masterList, manager, packetBuilder,
spellManager, itemManager, playerManager,
packetManager, ruleManager, databaseService,
)
// Initialize the system
adapter.Initialize()
```
### Adding Traits
```go
// Create a new trait
trait := &traits.TraitData{
SpellID: 12345,
Level: 10,
ClassReq: traits.UniversalClassReq, // Available to all classes
RaceReq: traits.UniversalRaceReq, // Available to all races
IsTrait: true,
IsInnate: false,
IsFocusEffect: false,
IsTraining: false,
Tier: 1,
Group: traits.TraitsCombat,
ItemID: 0,
}
// Add to system
err := adapter.AddTrait(trait)
if err != nil {
log.Printf("Failed to add trait: %v", err)
}
```
### Player Trait Operations
```go
// Get trait list packet for player
packetData, err := adapter.GetTraitListPacket(playerID)
if err != nil {
log.Printf("Failed to get trait list: %v", err)
}
// Check if player can select a trait
allowed, err := adapter.IsPlayerAllowedTrait(playerID, spellID)
if err != nil {
log.Printf("Failed to check trait allowance: %v", err)
}
// Process trait selections
selectedSpells := []uint32{12345, 67890}
err = adapter.SelectTraits(playerID, selectedSpells)
if err != nil {
log.Printf("Failed to select traits: %v", err)
}
// Get player trait statistics
stats, err := adapter.GetPlayerTraitStats(playerID)
if err != nil {
log.Printf("Failed to get player stats: %v", err)
}
```
### Event Handling
```go
// Create event handler
eventHandler := traits.NewTraitEventHandler(adapter)
// Handle level up
err := eventHandler.OnPlayerLevelUp(playerID, newLevel)
if err != nil {
log.Printf("Failed to handle level up: %v", err)
}
// Handle login
err = eventHandler.OnPlayerLogin(playerID)
if err != nil {
log.Printf("Failed to handle login: %v", err)
}
// Handle logout
eventHandler.OnPlayerLogout(playerID)
```
## Configuration
The system uses configurable rules for trait selection:
- **TieringSelection**: Enable/disable tiered trait selection logic
- **UseClassicLevelTable**: Use classic EQ2 level requirements vs. interval-based
- **FocusSelectLevel**: Level interval for focus effect availability (default: 9)
- **TrainingSelectLevel**: Level interval for training availability (default: 10)
- **RaceSelectLevel**: Level interval for racial trait availability (default: 10)
- **CharacterSelectLevel**: Level interval for character trait availability (default: 4)
## Packet System
### Trait List Packet (WS_TraitsList)
Contains comprehensive trait information for client display:
- **Character Traits**: Organized by level with up to 5 traits per level
- **Class Training**: Specialized abilities for the player's class
- **Racial Traits**: Grouped by category (Attributes, Combat, etc.)
- **Innate Abilities**: Automatic racial abilities
- **Focus Effects**: Advanced abilities (client version >= 1188)
### Trait Reward Packet (WS_QuestRewardPackMsg)
Used for trait selection during level-up:
- **Selection Rewards**: Available trait choices
- **Item Rewards**: Associated items for trait selection
- **Packet Type**: Determines UI presentation (0-3)
## Integration Interfaces
The system integrates with other game systems through well-defined interfaces:
- `SpellManager` - Spell information and player spell management
- `ItemManager` - Item operations for trait-associated items
- `PlayerManager` - Player information and messaging
- `PacketManager` - Client communication and versioning
- `RuleManager` - Configuration and rule access
- `DatabaseService` - Trait persistence and player state
## Tiered Selection Logic
The system supports sophisticated tiered selection logic:
1. **Group Processing**: Traits are processed by group to ensure balanced selection
2. **Spell Matching**: Previously selected spells influence future availability
3. **Priority System**: Different trait types have selection priority
4. **Validation**: Level and prerequisite requirements are enforced
## Thread Safety
All operations are thread-safe using Go's sync.RWMutex for optimal read performance during frequent access patterns.
## Performance
- Trait access: ~200ns per operation
- Trait list generation: ~50μs per player
- Memory usage: ~2KB per active player trait state
- Packet building: ~100μs per comprehensive trait packet
## Testing
Run the comprehensive test suite:
```bash
go test ./internal/traits/ -v
```
Benchmarks are included for performance-critical operations:
```bash
go test ./internal/traits/ -bench=.
```
## Migration from C++
This is a complete conversion from the original C++ implementation:
- `Traits.h``constants.go` + `types.go`
- `Traits.cpp``manager.go` + `packets.go`
All functionality has been preserved with Go-native patterns and improvements:
- Better error handling with typed errors
- Type safety with strongly-typed interfaces
- Comprehensive integration system
- Modern testing practices with benchmarks
- Performance optimizations for concurrent access
- Thread-safe operations with proper mutex usage
## Key Features
### Trait Management
- Complete trait registry with validation
- Player-specific trait state management
- Level-based trait availability calculations
- Classic EQ2 progression table support
### Selection Logic
- Tiered selection with group-based processing
- Prerequisite validation and enforcement
- Spell matching for progression continuity
- Multiple trait type support
### Packet System
- Comprehensive trait list packet building
- Client version compatibility
- Trait reward selection packets
- Empty slot filling for consistent UI
### Integration
- Seamless spell system integration
- Item association for trait rewards
- Player management integration
- Rule-based configuration system
- Database persistence for trait state
### Event System
- Level-up trait availability notifications
- Login/logout state management
- Automatic trait selection opportunities
- Player progression tracking
The traits system provides a complete, production-ready character advancement implementation that maintains full compatibility with the original EQ2 client while offering modern Go development practices and performance optimizations.

View File

@ -1,584 +0,0 @@
package traits
import (
"fmt"
"testing"
)
func TestTraitData(t *testing.T) {
trait := &TraitData{
SpellID: 12345,
Level: 10,
ClassReq: UniversalClassReq,
RaceReq: UniversalRaceReq,
IsTrait: true,
IsInnate: false,
IsFocusEffect: false,
IsTraining: false,
Tier: 1,
Group: TraitsCombat,
ItemID: 0,
}
// Test Copy method
copied := trait.Copy()
if copied == nil {
t.Fatal("Copy returned nil")
}
if copied.SpellID != trait.SpellID {
t.Errorf("Expected SpellID %d, got %d", trait.SpellID, copied.SpellID)
}
if copied.Level != trait.Level {
t.Errorf("Expected Level %d, got %d", trait.Level, copied.Level)
}
// Test Copy with nil
var nilTrait *TraitData
copiedNil := nilTrait.Copy()
if copiedNil != nil {
t.Error("Copy of nil should return nil")
}
// Test IsUniversalTrait
if !trait.IsUniversalTrait() {
t.Error("Trait should be universal")
}
// Test IsForClass
if !trait.IsForClass(5) {
t.Error("Universal trait should be available for any class")
}
// Test IsForRace
if !trait.IsForRace(3) {
t.Error("Universal trait should be available for any race")
}
// Test GetTraitType
traitType := trait.GetTraitType()
if traitType != "Character Trait" {
t.Errorf("Expected 'Character Trait', got '%s'", traitType)
}
// Test Validate
err := trait.Validate()
if err != nil {
t.Errorf("Valid trait should pass validation: %v", err)
}
// Test invalid trait
invalidTrait := &TraitData{
SpellID: 0, // Invalid
Level: -1, // Invalid
Group: 10, // Invalid
}
err = invalidTrait.Validate()
if err == nil {
t.Error("Invalid trait should fail validation")
}
}
func TestMasterTraitList(t *testing.T) {
masterList := NewMasterTraitList()
if masterList == nil {
t.Fatal("NewMasterTraitList returned nil")
}
// Test initial state
if masterList.Size() != 0 {
t.Error("New master list should be empty")
}
// Test AddTrait
trait := &TraitData{
SpellID: 12345,
Level: 10,
ClassReq: UniversalClassReq,
RaceReq: UniversalRaceReq,
IsTrait: true,
IsInnate: false,
IsFocusEffect: false,
IsTraining: false,
Tier: 1,
Group: TraitsCombat,
ItemID: 67890,
}
err := masterList.AddTrait(trait)
if err != nil {
t.Fatalf("AddTrait failed: %v", err)
}
if masterList.Size() != 1 {
t.Errorf("Expected size 1 after adding trait, got %d", masterList.Size())
}
// Test GetTrait
retrieved := masterList.GetTrait(12345)
if retrieved == nil {
t.Fatal("GetTrait returned nil")
}
if retrieved.SpellID != 12345 {
t.Errorf("Expected SpellID 12345, got %d", retrieved.SpellID)
}
// Test GetTraitByItemID
retrievedByItem := masterList.GetTraitByItemID(67890)
if retrievedByItem == nil {
t.Fatal("GetTraitByItemID returned nil")
}
if retrievedByItem.ItemID != 67890 {
t.Errorf("Expected ItemID 67890, got %d", retrievedByItem.ItemID)
}
// Test GetTrait with non-existent ID
nonExistent := masterList.GetTrait(99999)
if nonExistent != nil {
t.Error("GetTrait should return nil for non-existent trait")
}
// Test AddTrait with nil
err = masterList.AddTrait(nil)
if err == nil {
t.Error("AddTrait should fail with nil trait")
}
// Test DestroyTraits
masterList.DestroyTraits()
if masterList.Size() != 0 {
t.Error("Size should be 0 after DestroyTraits")
}
}
func TestPlayerTraitState(t *testing.T) {
playerState := NewPlayerTraitState(12345, 25, 1, 2)
if playerState == nil {
t.Fatal("NewPlayerTraitState returned nil")
}
if playerState.PlayerID != 12345 {
t.Errorf("Expected PlayerID 12345, got %d", playerState.PlayerID)
}
// Test UpdateLevel
playerState.UpdateLevel(30)
if playerState.Level != 30 {
t.Errorf("Expected level 30, got %d", playerState.Level)
}
if !playerState.NeedTraitUpdate {
t.Error("Should need trait update after level change")
}
// Test trait selection
playerState.SelectTrait(11111)
if !playerState.HasTrait(11111) {
t.Error("Player should have selected trait")
}
if playerState.GetSelectedTraitCount() != 1 {
t.Errorf("Expected 1 selected trait, got %d", playerState.GetSelectedTraitCount())
}
// Test trait unselection
playerState.UnselectTrait(11111)
if playerState.HasTrait(11111) {
t.Error("Player should not have unselected trait")
}
if playerState.GetSelectedTraitCount() != 0 {
t.Errorf("Expected 0 selected traits, got %d", playerState.GetSelectedTraitCount())
}
}
func TestTraitLists(t *testing.T) {
traitLists := NewTraitLists()
if traitLists == nil {
t.Fatal("NewTraitLists returned nil")
}
// Test initial state
if len(traitLists.SortedTraitList) != 0 {
t.Error("SortedTraitList should be empty initially")
}
// Test Clear
traitLists.SortedTraitList[0] = make(map[int8][]*TraitData)
traitLists.Clear()
if len(traitLists.SortedTraitList) != 0 {
t.Error("SortedTraitList should be empty after Clear")
}
}
func TestTraitSelectionContext(t *testing.T) {
context := NewTraitSelectionContext(true)
if context == nil {
t.Fatal("NewTraitSelectionContext returned nil")
}
if !context.TieredSelection {
t.Error("TieredSelection should be true")
}
if context.GroupToApply != UnassignedGroupID {
t.Error("GroupToApply should be UnassignedGroupID initially")
}
// Test Reset
context.GroupToApply = 5
context.FoundSpellMatch = true
context.Reset()
if context.GroupToApply != UnassignedGroupID {
t.Error("GroupToApply should be reset to UnassignedGroupID")
}
if context.FoundSpellMatch {
t.Error("FoundSpellMatch should be reset to false")
}
}
func TestTraitSystemConfig(t *testing.T) {
config := &TraitSystemConfig{
TieringSelection: true,
UseClassicLevelTable: false,
FocusSelectLevel: 9,
TrainingSelectLevel: 10,
RaceSelectLevel: 10,
CharacterSelectLevel: 4,
}
if !config.TieringSelection {
t.Error("TieringSelection should be true")
}
if config.FocusSelectLevel != 9 {
t.Errorf("Expected FocusSelectLevel 9, got %d", config.FocusSelectLevel)
}
}
func TestGenerateTraitLists(t *testing.T) {
masterList := NewMasterTraitList()
// Add test traits
traits := []*TraitData{
{
SpellID: 1001,
Level: 10,
ClassReq: UniversalClassReq,
RaceReq: UniversalRaceReq,
IsTrait: true,
Group: TraitsCombat,
},
{
SpellID: 1002,
Level: 15,
ClassReq: 5, // Specific class
IsTraining: true,
Group: TraitsAttributes,
},
{
SpellID: 1003,
Level: 20,
RaceReq: 3, // Specific race
Group: TraitsNoncombat,
},
{
SpellID: 1004,
Level: 25,
RaceReq: 3,
IsInnate: true,
Group: TraitsPools,
},
{
SpellID: 1005,
Level: 30,
ClassReq: 5,
IsFocusEffect: true,
Group: TraitsResist,
},
}
for _, trait := range traits {
masterList.AddTrait(trait)
}
playerState := NewPlayerTraitState(12345, 50, 5, 3)
// Test GenerateTraitLists
success := masterList.GenerateTraitLists(playerState, 50, UnassignedGroupID)
if !success {
t.Fatal("GenerateTraitLists should succeed")
}
// Check that traits were categorized correctly
// Should have 1 character trait (universal)
characterTraitFound := false
for _, levelMap := range playerState.TraitLists.SortedTraitList {
if len(levelMap) > 0 {
characterTraitFound = true
break
}
}
if !characterTraitFound {
t.Error("Should have character traits")
}
// Should have 1 class training trait
if len(playerState.TraitLists.ClassTraining) == 0 {
t.Error("Should have class training traits")
}
// Should have 1 racial trait
if len(playerState.TraitLists.RaceTraits) == 0 {
t.Error("Should have racial traits")
}
// Should have 1 innate racial trait
if len(playerState.TraitLists.InnateRaceTraits) == 0 {
t.Error("Should have innate racial traits")
}
// Should have 1 focus effect
if len(playerState.TraitLists.FocusEffects) == 0 {
t.Error("Should have focus effects")
}
}
func TestIsPlayerAllowedTrait(t *testing.T) {
masterList := NewMasterTraitList()
playerState := NewPlayerTraitState(12345, 20, 5, 3)
config := &TraitSystemConfig{
TieringSelection: false,
UseClassicLevelTable: false,
FocusSelectLevel: 9,
TrainingSelectLevel: 10,
RaceSelectLevel: 10,
CharacterSelectLevel: 4,
}
trait := &TraitData{
SpellID: 1001,
Level: 10,
ClassReq: UniversalClassReq,
RaceReq: UniversalRaceReq,
IsTrait: true,
Group: TraitsCombat,
}
masterList.AddTrait(trait)
// Generate trait lists
masterList.GenerateTraitLists(playerState, 50, UnassignedGroupID)
// Test trait allowance
allowed := masterList.IsPlayerAllowedTrait(playerState, trait, config)
if !allowed {
t.Error("Player should be allowed this trait")
}
}
func TestTraitManager(t *testing.T) {
masterList := NewMasterTraitList()
config := &TraitSystemConfig{
TieringSelection: false,
UseClassicLevelTable: false,
FocusSelectLevel: 9,
TrainingSelectLevel: 10,
RaceSelectLevel: 10,
CharacterSelectLevel: 4,
}
manager := NewTraitManager(masterList, config)
if manager == nil {
t.Fatal("NewTraitManager returned nil")
}
// Test GetPlayerState
playerState := manager.GetPlayerState(12345, 25, 5, 3)
if playerState == nil {
t.Fatal("GetPlayerState returned nil")
}
if playerState.PlayerID != 12345 {
t.Errorf("Expected PlayerID 12345, got %d", playerState.PlayerID)
}
// Test level update
playerState2 := manager.GetPlayerState(12345, 30, 5, 3)
if playerState2.Level != 30 {
t.Errorf("Expected level 30, got %d", playerState2.Level)
}
// Test ClearPlayerState
manager.ClearPlayerState(12345)
// Should create new state after clearing
playerState3 := manager.GetPlayerState(12345, 25, 5, 3)
if playerState3 == playerState {
t.Error("Should create new state after clearing")
}
}
func TestTraitPacketHelper(t *testing.T) {
helper := NewTraitPacketHelper()
if helper == nil {
t.Fatal("NewTraitPacketHelper returned nil")
}
// Test FormatTraitFieldName
fieldName := helper.FormatTraitFieldName("trait", 2, "_icon")
if fieldName != "trait2_icon" {
t.Errorf("Expected 'trait2_icon', got '%s'", fieldName)
}
// Test GetPacketTypeForTrait
trait := &TraitData{
ClassReq: UniversalClassReq,
RaceReq: UniversalRaceReq,
IsTrait: true,
IsTraining: false,
}
packetType := helper.GetPacketTypeForTrait(trait, 5, 3)
if packetType != PacketTypeCharacterTrait {
t.Errorf("Expected PacketTypeCharacterTrait (%d), got %d", PacketTypeCharacterTrait, packetType)
}
// Test CalculateAvailableSelections
available := helper.CalculateAvailableSelections(30, 2, 10)
if available != 1 {
t.Errorf("Expected 1 available selection, got %d", available)
}
// Test GetClassicLevelRequirement
levelReq := helper.GetClassicLevelRequirement(PersonalTraitLevelLimits, 2)
if levelReq != PersonalTraitLevelLimits[2] {
t.Errorf("Expected %d, got %d", PersonalTraitLevelLimits[2], levelReq)
}
// Test BuildEmptyTraitSlot
emptySlot := helper.BuildEmptyTraitSlot()
if emptySlot.SpellID != EmptyTraitID {
t.Errorf("Expected EmptyTraitID (%d), got %d", EmptyTraitID, emptySlot.SpellID)
}
// Test CountSelectedTraits
traits := []TraitInfo{
{Selected: true},
{Selected: false},
{Selected: true},
}
count := helper.CountSelectedTraits(traits)
if count != 2 {
t.Errorf("Expected 2 selected traits, got %d", count)
}
}
func TestTraitErrors(t *testing.T) {
// Test TraitError
err := NewTraitError("test error")
if err == nil {
t.Fatal("NewTraitError returned nil")
}
if err.Error() != "test error" {
t.Errorf("Expected 'test error', got '%s'", err.Error())
}
// Test IsTraitError
if !IsTraitError(err) {
t.Error("Should identify as trait error")
}
// Test with non-trait error
if IsTraitError(fmt.Errorf("not a trait error")) {
t.Error("Should not identify as trait error")
}
}
func TestConstants(t *testing.T) {
// Test trait group constants
if TraitsAttributes != 0 {
t.Errorf("Expected TraitsAttributes to be 0, got %d", TraitsAttributes)
}
if TraitsTradeskill != 5 {
t.Errorf("Expected TraitsTradeskill to be 5, got %d", TraitsTradeskill)
}
// Test level limits
if len(PersonalTraitLevelLimits) != 9 {
t.Errorf("Expected 9 personal trait level limits, got %d", len(PersonalTraitLevelLimits))
}
if PersonalTraitLevelLimits[1] != 8 {
t.Errorf("Expected first personal trait at level 8, got %d", PersonalTraitLevelLimits[1])
}
// Test trait group names
if TraitGroupNames[TraitsAttributes] != "Attributes" {
t.Errorf("Expected 'Attributes', got '%s'", TraitGroupNames[TraitsAttributes])
}
// Test constants
if MaxTraitsPerLine != 5 {
t.Errorf("Expected MaxTraitsPerLine to be 5, got %d", MaxTraitsPerLine)
}
if UniversalClassReq != -1 {
t.Errorf("Expected UniversalClassReq to be -1, got %d", UniversalClassReq)
}
}
func BenchmarkMasterTraitListAccess(b *testing.B) {
masterList := NewMasterTraitList()
// Add test traits
for i := 0; i < 1000; i++ {
trait := &TraitData{
SpellID: uint32(i + 1000),
Level: int8((i % 50) + 1),
Group: int8(i % 6),
}
masterList.AddTrait(trait)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
masterList.GetTrait(uint32((i % 1000) + 1000))
}
}
func BenchmarkGenerateTraitLists(b *testing.B) {
masterList := NewMasterTraitList()
// Add test traits
for i := 0; i < 100; i++ {
trait := &TraitData{
SpellID: uint32(i + 1000),
Level: int8((i % 50) + 1),
ClassReq: UniversalClassReq,
RaceReq: UniversalRaceReq,
IsTrait: true,
Group: int8(i % 6),
}
masterList.AddTrait(trait)
}
playerState := NewPlayerTraitState(12345, 50, 5, 3)
b.ResetTimer()
for i := 0; i < b.N; i++ {
masterList.GenerateTraitLists(playerState, 50, UnassignedGroupID)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,372 +0,0 @@
# 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