diff --git a/go.mod b/go.mod index c65c9f5..f8cd01a 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,11 @@ require ( zombiezen.com/go/sqlite v1.4.2 ) +require filippo.io/edwards25519 v1.1.0 // indirect + require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-sql-driver/mysql v1.9.3 github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect diff --git a/go.sum b/go.sum index 85eb73c..d72ff7a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/internal/database/database.go b/internal/database/database.go index 146c9e1..78a7039 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -5,21 +5,62 @@ import ( "fmt" "sync" + _ "github.com/go-sql-driver/mysql" _ "modernc.org/sqlite" "zombiezen.com/go/sqlite/sqlitex" ) +// DatabaseType represents the type of database backend +type DatabaseType int + +const ( + SQLite DatabaseType = iota + MySQL +) + +// Config holds database configuration +type Config struct { + Type DatabaseType + DSN string // Data Source Name (connection string) + PoolSize int // Connection pool size +} + // Database wraps the SQL database connection type Database struct { db *sql.DB - pool *sqlitex.Pool // For achievements system compatibility - dbPath string // Store path for pool creation + pool *sqlitex.Pool // For achievements system compatibility (SQLite only) + config Config mutex sync.RWMutex } -// New creates a new database connection -func New(path string) (*Database, error) { - db, err := sql.Open("sqlite", path) +// New creates a new database connection with the provided configuration +func New(config Config) (*Database, error) { + var driverName string + var pool *sqlitex.Pool + + // Set default pool size + if config.PoolSize == 0 { + config.PoolSize = 25 + } + + switch config.Type { + case SQLite: + driverName = "sqlite" + // Create sqlitex pool for achievements system compatibility + var err error + pool, err = sqlitex.NewPool(config.DSN, sqlitex.PoolOptions{ + PoolSize: 5, + }) + if err != nil { + return nil, fmt.Errorf("failed to create sqlite pool: %w", err) + } + case MySQL: + driverName = "mysql" + default: + return nil, fmt.Errorf("unsupported database type: %d", config.Type) + } + + db, err := sql.Open(driverName, config.DSN) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } @@ -30,26 +71,13 @@ func New(path string) (*Database, error) { } // Set connection pool settings - db.SetMaxOpenConns(25) - db.SetMaxIdleConns(5) - - // Create sqlitex pool for achievements system - pool, err := sqlitex.NewPool(path, sqlitex.PoolOptions{ - PoolSize: 5, - }) - if err != nil { - return nil, fmt.Errorf("failed to create sqlite pool: %w", err) - } + db.SetMaxOpenConns(config.PoolSize) + db.SetMaxIdleConns(config.PoolSize / 5) d := &Database{ db: db, pool: pool, - dbPath: path, - } - - // Initialize schema - if err := d.initSchema(); err != nil { - return nil, fmt.Errorf("failed to initialize schema: %w", err) + config: config, } return d, nil @@ -63,257 +91,16 @@ func (d *Database) Close() error { return d.db.Close() } +// GetType returns the database type +func (d *Database) GetType() DatabaseType { + return d.config.Type +} + // GetPool returns the sqlitex pool for achievements system compatibility func (d *Database) GetPool() *sqlitex.Pool { return d.pool } -// initSchema creates the database schema if it doesn't exist -func (d *Database) initSchema() error { - schemas := []string{ - // Rules table - `CREATE TABLE IF NOT EXISTS rules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - category TEXT NOT NULL, - name TEXT NOT NULL, - value TEXT NOT NULL, - description TEXT, - UNIQUE(category, name) - )`, - - // Accounts table - `CREATE TABLE IF NOT EXISTS accounts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL, - email TEXT, - admin_level INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_login DATETIME - )`, - - // Characters table - `CREATE TABLE IF NOT EXISTS characters ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - account_id INTEGER NOT NULL, - name TEXT UNIQUE NOT NULL, - race_id INTEGER NOT NULL, - class_id INTEGER NOT NULL, - level INTEGER DEFAULT 1, - x REAL DEFAULT 0, - y REAL DEFAULT 0, - z REAL DEFAULT 0, - heading REAL DEFAULT 0, - zone_id INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_played DATETIME, - FOREIGN KEY(account_id) REFERENCES accounts(id) - )`, - - // Zones table - `CREATE TABLE IF NOT EXISTS zones ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - file TEXT NOT NULL, - description TEXT, - motd TEXT, - min_level INTEGER DEFAULT 0, - max_level INTEGER DEFAULT 100, - min_version INTEGER DEFAULT 0, - xp_modifier REAL DEFAULT 1.0, - city_zone INTEGER DEFAULT 0, - weather_allowed INTEGER DEFAULT 1, - safe_x REAL DEFAULT 0, - safe_y REAL DEFAULT 0, - safe_z REAL DEFAULT 0, - safe_heading REAL DEFAULT 0 - )`, - - // Server statistics table - `CREATE TABLE IF NOT EXISTS server_stats ( - stat_id INTEGER PRIMARY KEY, - stat_value INTEGER, - stat_date INTEGER, - save_needed INTEGER DEFAULT 0 - )`, - - // Merchant tables - `CREATE TABLE IF NOT EXISTS merchants ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - merchant_type INTEGER DEFAULT 0 - )`, - - `CREATE TABLE IF NOT EXISTS merchant_items ( - merchant_id INTEGER NOT NULL, - item_id INTEGER NOT NULL, - quantity INTEGER DEFAULT -1, - price_coins INTEGER DEFAULT 0, - price_status INTEGER DEFAULT 0, - PRIMARY KEY(merchant_id, item_id), - FOREIGN KEY(merchant_id) REFERENCES merchants(id) - )`, - - // Achievement tables - `CREATE TABLE IF NOT EXISTS achievements ( - achievement_id INTEGER PRIMARY KEY, - title TEXT NOT NULL, - uncompleted_text TEXT, - completed_text TEXT, - category TEXT, - expansion TEXT, - icon INTEGER DEFAULT 0, - point_value INTEGER DEFAULT 0, - qty_req INTEGER DEFAULT 1, - hide_achievement INTEGER DEFAULT 0, - unknown3a INTEGER DEFAULT 0, - unknown3b INTEGER DEFAULT 0 - )`, - - `CREATE TABLE IF NOT EXISTS achievements_requirements ( - achievement_id INTEGER NOT NULL, - name TEXT NOT NULL, - qty_req INTEGER DEFAULT 1, - PRIMARY KEY(achievement_id, name), - FOREIGN KEY(achievement_id) REFERENCES achievements(achievement_id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS achievements_rewards ( - achievement_id INTEGER NOT NULL, - reward TEXT NOT NULL, - PRIMARY KEY(achievement_id, reward), - FOREIGN KEY(achievement_id) REFERENCES achievements(achievement_id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS character_achievements ( - char_id INTEGER NOT NULL, - achievement_id INTEGER NOT NULL, - completed_date INTEGER, - PRIMARY KEY(char_id, achievement_id), - FOREIGN KEY(char_id) REFERENCES characters(id) ON DELETE CASCADE, - FOREIGN KEY(achievement_id) REFERENCES achievements(achievement_id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS character_achievements_items ( - char_id INTEGER NOT NULL, - achievement_id INTEGER NOT NULL, - items INTEGER DEFAULT 0, - PRIMARY KEY(char_id, achievement_id), - FOREIGN KEY(char_id) REFERENCES characters(id) ON DELETE CASCADE, - FOREIGN KEY(achievement_id) REFERENCES achievements(achievement_id) ON DELETE CASCADE - )`, - - // Title tables - `CREATE TABLE IF NOT EXISTS titles ( - title_id INTEGER PRIMARY KEY, - text TEXT NOT NULL, - category INTEGER DEFAULT 0, - rarity INTEGER DEFAULT 0, - position INTEGER DEFAULT 0, - description TEXT, - is_unique INTEGER DEFAULT 0, - is_hidden INTEGER DEFAULT 0, - color_code TEXT, - requirements TEXT, - source_type INTEGER DEFAULT 0, - source_id INTEGER DEFAULT 0, - created_date INTEGER, - expire_date INTEGER - )`, - - `CREATE TABLE IF NOT EXISTS character_titles ( - char_id INTEGER NOT NULL, - title_id INTEGER NOT NULL, - source_achievement_id INTEGER DEFAULT 0, - source_quest_id INTEGER DEFAULT 0, - granted_date INTEGER, - expire_date INTEGER, - PRIMARY KEY(char_id, title_id), - FOREIGN KEY(char_id) REFERENCES characters(id) ON DELETE CASCADE, - FOREIGN KEY(title_id) REFERENCES titles(title_id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS character_active_titles ( - char_id INTEGER PRIMARY KEY, - prefix_title_id INTEGER DEFAULT 0, - suffix_title_id INTEGER DEFAULT 0, - FOREIGN KEY(char_id) REFERENCES characters(id) ON DELETE CASCADE, - FOREIGN KEY(prefix_title_id) REFERENCES titles(title_id) ON DELETE SET NULL, - FOREIGN KEY(suffix_title_id) REFERENCES titles(title_id) ON DELETE SET NULL - )`, - - // NPC tables - `CREATE TABLE IF NOT EXISTS npcs ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - level INTEGER DEFAULT 1, - max_level INTEGER DEFAULT 1, - race INTEGER DEFAULT 0, - model_type INTEGER DEFAULT 0, - size INTEGER DEFAULT 32, - hp INTEGER DEFAULT 100, - power INTEGER DEFAULT 100, - x REAL DEFAULT 0, - y REAL DEFAULT 0, - z REAL DEFAULT 0, - heading REAL DEFAULT 0, - respawn_time INTEGER DEFAULT 300, - zone_id INTEGER DEFAULT 0, - aggro_radius REAL DEFAULT 10, - ai_strategy INTEGER DEFAULT 0, - loot_table_id INTEGER DEFAULT 0, - merchant_type INTEGER DEFAULT 0, - randomize_appearance INTEGER DEFAULT 0, - show_name INTEGER DEFAULT 1, - show_level INTEGER DEFAULT 1, - targetable INTEGER DEFAULT 1, - show_command_icon INTEGER DEFAULT 1, - display_hand_icon INTEGER DEFAULT 0, - faction_id INTEGER DEFAULT 0, - created_date INTEGER, - last_modified INTEGER - )`, - - `CREATE TABLE IF NOT EXISTS npc_spells ( - npc_id INTEGER NOT NULL, - spell_id INTEGER NOT NULL, - tier INTEGER DEFAULT 1, - hp_percentage INTEGER DEFAULT 100, - priority INTEGER DEFAULT 1, - cast_type INTEGER DEFAULT 0, - recast_delay INTEGER DEFAULT 5, - PRIMARY KEY(npc_id, spell_id), - FOREIGN KEY(npc_id) REFERENCES npcs(id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS npc_skills ( - npc_id INTEGER NOT NULL, - skill_name TEXT NOT NULL, - skill_value INTEGER DEFAULT 0, - max_value INTEGER DEFAULT 0, - PRIMARY KEY(npc_id, skill_name), - FOREIGN KEY(npc_id) REFERENCES npcs(id) ON DELETE CASCADE - )`, - - `CREATE TABLE IF NOT EXISTS npc_loot ( - npc_id INTEGER NOT NULL, - item_id INTEGER NOT NULL, - probability REAL DEFAULT 100.0, - min_level INTEGER DEFAULT 0, - max_level INTEGER DEFAULT 100, - PRIMARY KEY(npc_id, item_id), - FOREIGN KEY(npc_id) REFERENCES npcs(id) ON DELETE CASCADE - )`, - } - - for _, schema := range schemas { - if _, err := d.db.Exec(schema); err != nil { - return fmt.Errorf("failed to create schema: %w", err) - } - } - - return nil -} // Query executes a query that returns rows func (d *Database) Query(query string, args ...interface{}) (*sql.Rows, error) { @@ -369,6 +156,23 @@ func (d *Database) SaveRule(category, name, value, description string) error { return err } +// NewSQLite creates a new SQLite database connection +func NewSQLite(path string) (*Database, error) { + return New(Config{ + Type: SQLite, + DSN: path, + }) +} + +// NewMySQL creates a new MySQL/MariaDB database connection +// DSN format: user:password@tcp(host:port)/database +func NewMySQL(dsn string) (*Database, error) { + return New(Config{ + Type: MySQL, + DSN: dsn, + }) +} + // GetZones retrieves all zones from the database func (d *Database) GetZones() ([]map[string]interface{}, error) { rows, err := d.Query(` diff --git a/internal/database/database_test.go b/internal/database/database_test.go new file mode 100644 index 0000000..cbfe129 --- /dev/null +++ b/internal/database/database_test.go @@ -0,0 +1,108 @@ +package database + +import ( + "testing" +) + +func TestNewSQLite(t *testing.T) { + // Test SQLite connection + db, err := NewSQLite("file::memory:?mode=memory&cache=shared") + if err != nil { + t.Fatalf("Failed to create SQLite database: %v", err) + } + defer db.Close() + + // Test database type + if db.GetType() != SQLite { + t.Errorf("Expected SQLite database type, got %v", db.GetType()) + } + + // Test basic query + result, err := db.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)") + if err != nil { + t.Fatalf("Failed to create test table: %v", err) + } + + affected, err := result.RowsAffected() + if err != nil { + t.Fatalf("Failed to get rows affected: %v", err) + } + + if affected != 0 { + t.Errorf("Expected 0 rows affected for CREATE TABLE, got %d", affected) + } + + // Test insert + _, err = db.Exec("INSERT INTO test (name) VALUES (?)", "test_value") + if err != nil { + t.Fatalf("Failed to insert test data: %v", err) + } + + // Test query + var name string + err = db.QueryRow("SELECT name FROM test WHERE id = 1").Scan(&name) + if err != nil { + t.Fatalf("Failed to query test data: %v", err) + } + + if name != "test_value" { + t.Errorf("Expected 'test_value', got '%s'", name) + } +} + +func TestConfigValidation(t *testing.T) { + tests := []struct { + name string + config Config + wantErr bool + }{ + { + name: "valid_sqlite_config", + config: Config{ + Type: SQLite, + DSN: "file::memory:?mode=memory&cache=shared", + }, + wantErr: false, + }, + { + name: "invalid_database_type", + config: Config{ + Type: DatabaseType(99), + DSN: "test", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, err := New(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr) + return + } + if db != nil { + db.Close() + } + }) + } +} + +func TestDatabaseTypeMethods(t *testing.T) { + // Test SQLite + db, err := NewSQLite("file::memory:?mode=memory&cache=shared") + if err != nil { + t.Fatalf("Failed to create SQLite database: %v", err) + } + defer db.Close() + + if db.GetType() != SQLite { + t.Errorf("Expected SQLite type, got %v", db.GetType()) + } + + // Verify GetPool works for SQLite + pool := db.GetPool() + if pool == nil { + t.Error("Expected non-nil pool for SQLite database") + } +} \ No newline at end of file diff --git a/internal/world/README.md b/internal/world/README.md index d334e9c..320c92d 100644 --- a/internal/world/README.md +++ b/internal/world/README.md @@ -108,13 +108,39 @@ JSON-based configuration with CLI overrides: "listen_port": 9000, "max_clients": 1000, "web_port": 8080, - "database_path": "world.db", + "database_type": "sqlite", + "database_path": "eq2.db", + "database_host": "localhost", + "database_port": 3306, + "database_name": "eq2emu", + "database_user": "eq2", + "database_pass": "password", "server_name": "EQ2Go World Server", "xp_rate": 1.0, "ts_xp_rate": 1.0 } ``` +For MySQL/MariaDB configuration: +```json +{ + "database_type": "mysql", + "database_host": "localhost", + "database_port": 3306, + "database_name": "eq2emu", + "database_user": "eq2", + "database_pass": "password" +} +``` + +For SQLite configuration (default): +```json +{ + "database_type": "sqlite", + "database_path": "eq2.db" +} +``` + ## Usage ### Basic Startup diff --git a/internal/world/world.go b/internal/world/world.go index ecb5ebb..1af7a2a 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -3,6 +3,7 @@ package world import ( "context" "fmt" + "strings" "sync" "time" @@ -78,7 +79,13 @@ type WorldConfig struct { WebKeyPassword string `json:"web_key_password"` // Database settings - DatabasePath string `json:"database_path"` + DatabaseType string `json:"database_type"` // "sqlite" or "mysql" + DatabasePath string `json:"database_path"` // For SQLite: file path + DatabaseHost string `json:"database_host"` // For MySQL: hostname + DatabasePort int `json:"database_port"` // For MySQL: port + DatabaseName string `json:"database_name"` // For MySQL: database name + DatabaseUser string `json:"database_user"` // For MySQL: username + DatabasePass string `json:"database_pass"` // For MySQL: password // Server settings ServerName string `json:"server_name"` @@ -138,7 +145,25 @@ type ServerStatistics struct { // NewWorld creates a new world server instance func NewWorld(config *WorldConfig) (*World, error) { // Initialize database - db, err := database.New(config.DatabasePath) + var db *database.Database + var err error + + switch strings.ToLower(config.DatabaseType) { + case "mysql", "mariadb": + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci", + config.DatabaseUser, config.DatabasePass, config.DatabaseHost, config.DatabasePort, config.DatabaseName) + db, err = database.NewMySQL(dsn) + case "sqlite", "": + // Default to SQLite if not specified + dbPath := config.DatabasePath + if dbPath == "" { + dbPath = "eq2.db" + } + db, err = database.NewSQLite(dbPath) + default: + return nil, fmt.Errorf("unsupported database type: %s", config.DatabaseType) + } + if err != nil { return nil, fmt.Errorf("failed to initialize database: %w", err) }