allow mysql alongside sqlite

This commit is contained in:
Sky Johnson 2025-08-07 11:19:42 -05:00
parent 31dbfa0fc3
commit 41f80008c9
6 changed files with 240 additions and 270 deletions

3
go.mod
View File

@ -7,8 +7,11 @@ require (
zombiezen.com/go/sqlite v1.4.2 zombiezen.com/go/sqlite v1.4.2
) )
require filippo.io/edwards25519 v1.1.0 // indirect
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect 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/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect

4
go.sum
View File

@ -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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=

View File

@ -5,21 +5,62 @@ import (
"fmt" "fmt"
"sync" "sync"
_ "github.com/go-sql-driver/mysql"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"zombiezen.com/go/sqlite/sqlitex" "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 // Database wraps the SQL database connection
type Database struct { type Database struct {
db *sql.DB db *sql.DB
pool *sqlitex.Pool // For achievements system compatibility pool *sqlitex.Pool // For achievements system compatibility (SQLite only)
dbPath string // Store path for pool creation config Config
mutex sync.RWMutex mutex sync.RWMutex
} }
// New creates a new database connection // New creates a new database connection with the provided configuration
func New(path string) (*Database, error) { func New(config Config) (*Database, error) {
db, err := sql.Open("sqlite", path) 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 { if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err) return nil, fmt.Errorf("failed to open database: %w", err)
} }
@ -30,26 +71,13 @@ func New(path string) (*Database, error) {
} }
// Set connection pool settings // Set connection pool settings
db.SetMaxOpenConns(25) db.SetMaxOpenConns(config.PoolSize)
db.SetMaxIdleConns(5) db.SetMaxIdleConns(config.PoolSize / 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)
}
d := &Database{ d := &Database{
db: db, db: db,
pool: pool, pool: pool,
dbPath: path, config: config,
}
// Initialize schema
if err := d.initSchema(); err != nil {
return nil, fmt.Errorf("failed to initialize schema: %w", err)
} }
return d, nil return d, nil
@ -63,257 +91,16 @@ func (d *Database) Close() error {
return d.db.Close() 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 // GetPool returns the sqlitex pool for achievements system compatibility
func (d *Database) GetPool() *sqlitex.Pool { func (d *Database) GetPool() *sqlitex.Pool {
return d.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 // Query executes a query that returns rows
func (d *Database) Query(query string, args ...interface{}) (*sql.Rows, error) { 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 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 // GetZones retrieves all zones from the database
func (d *Database) GetZones() ([]map[string]interface{}, error) { func (d *Database) GetZones() ([]map[string]interface{}, error) {
rows, err := d.Query(` rows, err := d.Query(`

View File

@ -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")
}
}

View File

@ -108,13 +108,39 @@ JSON-based configuration with CLI overrides:
"listen_port": 9000, "listen_port": 9000,
"max_clients": 1000, "max_clients": 1000,
"web_port": 8080, "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", "server_name": "EQ2Go World Server",
"xp_rate": 1.0, "xp_rate": 1.0,
"ts_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 ## Usage
### Basic Startup ### Basic Startup

View File

@ -3,6 +3,7 @@ package world
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"sync" "sync"
"time" "time"
@ -78,7 +79,13 @@ type WorldConfig struct {
WebKeyPassword string `json:"web_key_password"` WebKeyPassword string `json:"web_key_password"`
// Database settings // 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 // Server settings
ServerName string `json:"server_name"` ServerName string `json:"server_name"`
@ -138,7 +145,25 @@ type ServerStatistics struct {
// NewWorld creates a new world server instance // NewWorld creates a new world server instance
func NewWorld(config *WorldConfig) (*World, error) { func NewWorld(config *WorldConfig) (*World, error) {
// Initialize database // 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 { if err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err) return nil, fmt.Errorf("failed to initialize database: %w", err)
} }