first pass on login server

This commit is contained in:
Sky Johnson 2025-07-30 08:36:38 -05:00
parent 555e2aaa8f
commit 82d03c2624
10 changed files with 2125 additions and 0 deletions

230
cmd/login_server/client.go Normal file
View File

@ -0,0 +1,230 @@
package main
import (
"eq2emu/internal/common/opcodes"
"eq2emu/internal/udp"
"fmt"
"log"
"time"
)
// LoginClient represents a connected client session
type LoginClient struct {
connection *udp.Connection
server *LoginServer
account *Account
lastActivity time.Time
authenticated bool
version uint16
sessionID string
// Client state
needsWorldList bool
sentCharacterList bool
pendingPlayCharID int32
createRequest *CharacterCreateRequest
}
// Account represents an authenticated user account
type Account struct {
ID int32
Username string
LSAdmin bool
WorldAdmin bool
Characters []*Character
LastLogin time.Time
IPAddress string
ClientVersion uint16
}
// Character represents a character in the database
type Character struct {
ID int32
AccountID int32
ServerID int32
Name string
Level int8
Race int8
Class int8
Gender int8
CreatedDate time.Time
Deleted bool
}
// CharacterCreateRequest holds pending character creation data
type CharacterCreateRequest struct {
ServerID int32
Name string
Race int8
Gender int8
Class int8
Face int8
Hair int8
HairColor int8
SkinColor int8
EyeColor int8
Timestamp time.Time
}
// NewLoginClient creates a new login client instance
func NewLoginClient(conn *udp.Connection, server *LoginServer) *LoginClient {
return &LoginClient{
connection: conn,
server: server,
lastActivity: time.Now(),
sessionID: fmt.Sprintf("%d", conn.GetSessionID()),
needsWorldList: true,
sentCharacterList: false,
}
}
// ProcessPacket handles incoming packets from the client
func (lc *LoginClient) ProcessPacket(packet *udp.ApplicationPacket) {
lc.lastActivity = time.Now()
switch packet.Opcode {
case opcodes.OpLoginRequestMsg:
lc.handleLoginRequest(packet)
case opcodes.OpAllWSDescRequestMsg:
lc.handleWorldListRequest(packet)
case opcodes.OpAllCharactersDescRequestMsg:
lc.handleCharacterListRequest(packet)
case opcodes.OpCreateCharacterRequestMsg:
lc.handleCharacterCreateRequest(packet)
case opcodes.OpDeleteCharacterRequestMsg:
lc.handleCharacterDeleteRequest(packet)
case opcodes.OpPlayCharacterRequestMsg:
lc.handlePlayCharacterRequest(packet)
case opcodes.OpKeymapLoadMsg:
// Client keymap request - usually ignored
break
default:
log.Printf("Unknown packet opcode from client %s: 0x%04X", lc.sessionID, packet.Opcode)
}
}
// handleLoginRequest processes client login attempts
func (lc *LoginClient) handleLoginRequest(packet *udp.ApplicationPacket) {
lc.server.IncrementLoginAttempts()
// Parse login request packet
loginReq, err := lc.parseLoginRequest(packet.Data)
if err != nil {
log.Printf("Failed to parse login request from %s: %v", lc.sessionID, err)
lc.sendLoginDenied()
return
}
lc.version = loginReq.Version
// Check if client version is supported
if !lc.server.GetConfig().IsVersionSupported(lc.version) {
log.Printf("Unsupported client version %d from %s", lc.version, lc.sessionID)
lc.sendLoginDeniedBadVersion()
return
}
// Authenticate with database
account, err := lc.server.database.AuthenticateAccount(loginReq.Username, loginReq.Password)
if err != nil {
log.Printf("Authentication failed for %s: %v", loginReq.Username, err)
lc.sendLoginDenied()
return
}
if account == nil {
log.Printf("Invalid credentials for %s", loginReq.Username)
lc.sendLoginDenied()
return
}
// Check for existing session
lc.server.clientMutex.RLock()
for _, existingClient := range lc.server.clients {
if existingClient.account != nil && existingClient.account.ID == account.ID && existingClient != lc {
log.Printf("Account %s already logged in, disconnecting previous session", account.Username)
existingClient.Disconnect()
break
}
}
lc.server.clientMutex.RUnlock()
// Update account info
account.LastLogin = time.Now()
account.IPAddress = lc.connection.GetStats().State.String() // Get IP from connection
account.ClientVersion = lc.version
lc.server.database.UpdateAccountLogin(account)
lc.account = account
lc.authenticated = true
lc.server.IncrementSuccessfulLogins()
log.Printf("User %s successfully authenticated", account.Username)
lc.sendLoginAccepted()
}
// handleWorldListRequest sends the list of available world servers
func (lc *LoginClient) handleWorldListRequest(packet *udp.ApplicationPacket) {
if !lc.authenticated {
lc.Disconnect()
return
}
lc.sendWorldList()
lc.needsWorldList = false
// Load and send character list
if !lc.sentCharacterList {
characters, err := lc.server.database.LoadCharacters(lc.account.ID, lc.version)
if err != nil {
log.Printf("Failed to load characters for account %d: %v", lc.account.ID, err)
} else {
lc.account.Characters = characters
lc.sentCharacterList = true
}
lc.sendCharacterList()
}
}
// handleCharacterListRequest handles explicit character list requests
func (lc *LoginClient) handleCharacterListRequest(packet *udp.ApplicationPacket) {
if !lc.authenticated {
lc.Disconnect()
return
}
lc.sendCharacterList()
}
// IsStale returns true if the client connection should be cleaned up
func (lc *LoginClient) IsStale() bool {
return time.Since(lc.lastActivity) > 5*time.Minute
}
// Disconnect closes the client connection and cleans up
func (lc *LoginClient) Disconnect() {
if lc.connection != nil {
lc.connection.Close()
}
// Clean up any pending requests
lc.createRequest = nil
log.Printf("Client %s disconnected", lc.sessionID)
lc.server.RemoveClient(lc.sessionID)
}
// GetAccount returns the authenticated account
func (lc *LoginClient) GetAccount() *Account {
return lc.account
}
// GetVersion returns the client version
func (lc *LoginClient) GetVersion() uint16 {
return lc.version
}
// GetSessionID returns the session identifier
func (lc *LoginClient) GetSessionID() string {
return lc.sessionID
}

163
cmd/login_server/config.go Normal file
View File

@ -0,0 +1,163 @@
package main
import (
"encoding/json"
"fmt"
"os"
)
// Config holds all login server configuration
type Config struct {
Port int `json:"port"`
MaxConnections int `json:"max_connections"`
TimeoutSeconds int `json:"timeout_seconds"`
EnableCompression bool `json:"enable_compression"`
EnableEncryption bool `json:"enable_encryption"`
AllowAccountCreation bool `json:"allow_account_creation"`
DefaultSubscriptionLevel uint32 `json:"default_subscription_level"`
ExpansionFlag uint16 `json:"expansion_flag"`
CitiesFlag uint8 `json:"cities_flag"`
EnabledRaces uint32 `json:"enabled_races"`
SupportedVersions []uint16 `json:"supported_versions"`
Database DatabaseConfig `json:"database"`
WebServer WebServerConfig `json:"web_server"`
WorldServers []WorldServerConfig `json:"world_servers"`
}
// WebServerConfig holds web server settings
type WebServerConfig struct {
Enabled bool `json:"enabled"`
Address string `json:"address"`
Port int `json:"port"`
CertFile string `json:"cert_file"`
KeyFile string `json:"key_file"`
KeyPassword string `json:"key_password"`
Username string `json:"username"`
Password string `json:"password"`
}
// WorldServerConfig holds world server connection info
type WorldServerConfig struct {
ID int32 `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Port int `json:"port"`
SharedKey string `json:"shared_key"`
AutoConnect bool `json:"auto_connect"`
}
// DefaultConfig returns sensible default configuration
func DefaultConfig() *Config {
return &Config{
Port: 5999,
MaxConnections: 1000,
TimeoutSeconds: 45,
EnableCompression: true,
EnableEncryption: true,
AllowAccountCreation: false,
DefaultSubscriptionLevel: 0xFFFFFFFF,
ExpansionFlag: 0x7CFF,
CitiesFlag: 0xFF,
EnabledRaces: 0xFFFF,
SupportedVersions: []uint16{283, 373, 546, 561, 1096, 1208},
Database: DatabaseConfig{
FilePath: "eq2login.db",
MaxConnections: 10,
BusyTimeout: 5000,
},
WebServer: WebServerConfig{
Enabled: true,
Address: "0.0.0.0",
Port: 8080,
},
}
}
// LoadConfig loads configuration from a JSON file
func LoadConfig(filename string) (*Config, error) {
// Start with defaults
config := DefaultConfig()
// Check if config file exists
if _, err := os.Stat(filename); os.IsNotExist(err) {
// Create default config file
if err := SaveConfig(filename, config); err != nil {
return nil, fmt.Errorf("failed to create default config: %w", err)
}
fmt.Printf("Created default configuration file: %s\n", filename)
return config, nil
}
// Read existing config file
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Parse JSON
if err := json.Unmarshal(data, config); err != nil {
return nil, fmt.Errorf("failed to parse config JSON: %w", err)
}
// Validate configuration
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return config, nil
}
// SaveConfig saves configuration to a JSON file
func SaveConfig(filename string, config *Config) error {
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(filename, data, 0644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
if c.Port < 1 || c.Port > 65535 {
return fmt.Errorf("invalid port: %d", c.Port)
}
if c.MaxConnections < 1 {
return fmt.Errorf("max_connections must be positive")
}
if c.TimeoutSeconds < 1 {
return fmt.Errorf("timeout_seconds must be positive")
}
if len(c.SupportedVersions) == 0 {
return fmt.Errorf("must specify at least one supported version")
}
return nil
}
// IsVersionSupported checks if a client version is supported
func (c *Config) IsVersionSupported(version uint16) bool {
for _, supported := range c.SupportedVersions {
if supported == version {
return true
}
}
return false
}
// GetWorldServerConfig returns configuration for a specific world server
func (c *Config) GetWorldServerConfig(id int32) *WorldServerConfig {
for i := range c.WorldServers {
if c.WorldServers[i].ID == id {
return &c.WorldServers[i]
}
}
return nil
}

View File

@ -0,0 +1,395 @@
package main
import (
"fmt"
"log"
"time"
"golang.org/x/crypto/bcrypt"
"zombiezen.com/go/sqlite"
"zombiezen.com/go/sqlite/sqlitex"
)
// Database handles all database operations for the login server
type Database struct {
conn *sqlite.Conn
}
// DatabaseConfig holds database connection settings
type DatabaseConfig struct {
FilePath string `json:"file_path"`
MaxConnections int `json:"max_connections"`
BusyTimeout int `json:"busy_timeout_ms"`
}
// NewDatabase creates a new database connection
func NewDatabase(config DatabaseConfig) (*Database, error) {
// Open SQLite database
db, err := sqlite.OpenConn(config.FilePath, sqlite.OpenReadWrite|sqlite.OpenCreate)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Enable foreign keys
if err := sqlitex.ExecuteTransient(db, "PRAGMA foreign_keys = ON", nil); err != nil {
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
}
// Set busy timeout
if config.BusyTimeout > 0 {
query := fmt.Sprintf("PRAGMA busy_timeout = %d", config.BusyTimeout)
if err := sqlitex.ExecuteTransient(db, query, nil); err != nil {
return nil, fmt.Errorf("failed to set busy timeout: %w", err)
}
}
log.Println("SQLite database connection established")
return &Database{conn: db}, nil
}
// Close closes the database connection
func (d *Database) Close() error {
return d.conn.Close()
}
// AuthenticateAccount verifies user credentials and returns account info
func (d *Database) AuthenticateAccount(username, password string) (*Account, error) {
query := `
SELECT id, username, password_hash, ls_admin, world_admin,
created_date, last_login, client_version
FROM accounts
WHERE username = ? AND active = 1`
stmt, err := d.conn.Prepare(query)
if err != nil {
return nil, fmt.Errorf("prepare statement failed: %w", err)
}
defer stmt.Finalize()
stmt.BindText(1, username)
hasRow, err := stmt.Step()
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
if !hasRow {
return nil, nil // Account not found
}
var account Account
var passwordHash string
var createdDate, lastLogin string
var clientVersion int64
account.ID = int32(stmt.ColumnInt64(0))
account.Username = stmt.ColumnText(1)
passwordHash = stmt.ColumnText(2)
account.LSAdmin = stmt.ColumnInt(3) != 0
account.WorldAdmin = stmt.ColumnInt(4) != 0
createdDate = stmt.ColumnText(5)
lastLogin = stmt.ColumnText(6)
clientVersion = stmt.ColumnInt64(7)
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
return nil, nil // Invalid password
}
// Parse timestamps
if lastLogin != "" {
if t, err := time.Parse("2006-01-02 15:04:05", lastLogin); err == nil {
account.LastLogin = t
}
}
account.ClientVersion = uint16(clientVersion)
return &account, nil
}
// UpdateAccountLogin updates account login timestamp and IP
func (d *Database) UpdateAccountLogin(account *Account) error {
query := `
UPDATE accounts
SET last_login = ?, last_ip = ?, client_version = ?
WHERE id = ?`
return sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{
Args: []any{
account.LastLogin.Format("2006-01-02 15:04:05"),
account.IPAddress,
account.ClientVersion,
account.ID,
},
})
}
// LoadCharacters loads all characters for an account
func (d *Database) LoadCharacters(accountID int32, version uint16) ([]*Character, error) {
query := `
SELECT id, server_id, name, level, race, gender, class,
created_date, deleted
FROM characters
WHERE account_id = ?
ORDER BY created_date ASC`
stmt, err := d.conn.Prepare(query)
if err != nil {
return nil, fmt.Errorf("prepare failed: %w", err)
}
defer stmt.Finalize()
stmt.BindInt64(1, int64(accountID))
var characters []*Character
for {
hasRow, err := stmt.Step()
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
if !hasRow {
break
}
char := &Character{AccountID: accountID}
char.ID = int32(stmt.ColumnInt64(0))
char.ServerID = int32(stmt.ColumnInt64(1))
char.Name = stmt.ColumnText(2)
char.Level = int8(stmt.ColumnInt(3))
char.Race = int8(stmt.ColumnInt(4))
char.Gender = int8(stmt.ColumnInt(5))
char.Class = int8(stmt.ColumnInt(6))
if dateStr := stmt.ColumnText(7); dateStr != "" {
if t, err := time.Parse("2006-01-02 15:04:05", dateStr); err == nil {
char.CreatedDate = t
}
}
char.Deleted = stmt.ColumnInt(8) != 0
characters = append(characters, char)
}
return characters, stmt.Err()
}
// CharacterNameExists checks if a character name is already taken
func (d *Database) CharacterNameExists(name string, serverID int32) (bool, error) {
query := `
SELECT COUNT(*)
FROM characters
WHERE name = ? AND server_id = ? AND deleted = 0`
stmt, err := d.conn.Prepare(query)
if err != nil {
return false, err
}
defer stmt.Finalize()
stmt.BindText(1, name)
stmt.BindInt64(2, int64(serverID))
hasRow, err := stmt.Step()
if err != nil {
return false, err
}
if hasRow {
return stmt.ColumnInt(0) > 0, nil
}
return false, nil
}
// CreateCharacter creates a new character in the database
func (d *Database) CreateCharacter(char *Character) (int32, error) {
query := `
INSERT INTO characters (account_id, server_id, name, level, race,
gender, class, created_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
err := sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{
Args: []any{
char.AccountID, char.ServerID, char.Name, char.Level,
char.Race, char.Gender, char.Class,
char.CreatedDate.Format("2006-01-02 15:04:05"),
},
})
if err != nil {
return 0, fmt.Errorf("failed to create character: %w", err)
}
id := int32(d.conn.LastInsertRowID())
log.Printf("Created character %s (ID: %d) for account %d",
char.Name, id, char.AccountID)
return id, nil
}
// DeleteCharacter marks a character as deleted
func (d *Database) DeleteCharacter(charID, accountID int32) error {
query := `
UPDATE characters
SET deleted = 1, deleted_date = CURRENT_TIMESTAMP
WHERE id = ? AND account_id = ?`
err := sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{
Args: []any{charID, accountID},
})
if err != nil {
return fmt.Errorf("failed to delete character: %w", err)
}
// Check if any rows were affected
stmt, _ := d.conn.Prepare("SELECT changes()")
defer stmt.Finalize()
if stmt.Step() && stmt.ColumnInt(0) == 0 {
return fmt.Errorf("character not found or not owned by account")
}
log.Printf("Deleted character %d for account %d", charID, accountID)
return nil
}
// GetWorldServers returns all configured world servers
func (d *Database) GetWorldServers() ([]*WorldServer, error) {
query := `
SELECT id, name, description, ip_address, port, status,
population, locked, hidden, created_date
FROM world_servers
WHERE active = 1
ORDER BY sort_order, name`
stmt, err := d.conn.Prepare(query)
if err != nil {
return nil, fmt.Errorf("prepare failed: %w", err)
}
defer stmt.Finalize()
var servers []*WorldServer
for {
hasRow, err := stmt.Step()
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
if !hasRow {
break
}
server := &WorldServer{}
server.ID = int32(stmt.ColumnInt64(0))
server.Name = stmt.ColumnText(1)
server.Description = stmt.ColumnText(2)
server.IPAddress = stmt.ColumnText(3)
server.Port = stmt.ColumnInt(4)
server.Status = stmt.ColumnText(5)
server.Population = int32(stmt.ColumnInt64(6))
server.Locked = stmt.ColumnInt(7) != 0
server.Hidden = stmt.ColumnInt(8) != 0
if dateStr := stmt.ColumnText(9); dateStr != "" {
if t, err := time.Parse("2006-01-02 15:04:05", dateStr); err == nil {
server.CreatedDate = t
}
}
server.Online = server.Status == "online"
server.PopulationLevel = d.calculatePopulationLevel(server.Population)
servers = append(servers, server)
}
return servers, stmt.Err()
}
// UpdateWorldServerStats updates world server statistics
func (d *Database) UpdateWorldServerStats(serverID int32, stats *WorldServerStats) error {
query := `
INSERT OR REPLACE INTO world_server_stats
(server_id, timestamp, population, zones_active, players_online, uptime_seconds)
VALUES (?, CURRENT_TIMESTAMP, ?, ?, ?, ?)`
return sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{
Args: []any{
serverID, stats.Population,
stats.ZonesActive, stats.PlayersOnline, stats.UptimeSeconds,
},
})
}
// CleanupOldEntries removes old log entries and statistics
func (d *Database) CleanupOldEntries() error {
queries := []string{
"DELETE FROM login_attempts WHERE timestamp < datetime('now', '-30 days')",
"DELETE FROM world_server_stats WHERE timestamp < datetime('now', '-7 days')",
"DELETE FROM client_logs WHERE timestamp < datetime('now', '-14 days')",
}
for _, query := range queries {
if err := sqlitex.ExecuteTransient(d.conn, query, nil); err != nil {
log.Printf("Cleanup query failed: %v", err)
}
}
return nil
}
// LogLoginAttempt records a login attempt for security monitoring
func (d *Database) LogLoginAttempt(username, ipAddress string, success bool) error {
query := `
INSERT INTO login_attempts (username, ip_address, success, timestamp)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)`
return sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{
Args: []any{username, ipAddress, success},
})
}
// GetMaxCharsSetting returns the maximum characters per account
func (d *Database) GetMaxCharsSetting() int32 {
var maxChars int32 = 7 // Default
query := "SELECT value FROM server_settings WHERE name = 'max_characters_per_account'"
stmt, err := d.conn.Prepare(query)
if err != nil {
return maxChars
}
defer stmt.Finalize()
hasRow, err := stmt.Step()
if err != nil {
return maxChars
}
if hasRow {
if val := stmt.ColumnText(0); val != "" {
if parsed := stmt.ColumnInt64(0); parsed > 0 {
maxChars = int32(parsed)
}
}
}
return maxChars
}
// GetAccountBonus returns veteran bonus flags for an account
func (d *Database) GetAccountBonus(accountID int32) uint8 {
var bonus uint8 = 0
query := "SELECT veteran_bonus FROM accounts WHERE id = ?"
stmt, err := d.conn.Prepare(query)
if err != nil {
return bonus
}
defer stmt.Finalize()
stmt.BindInt64(1, int64(accountID))
hasRow, err := stmt.Step()
if err != nil {
return bonus
}
if hasRow {
bonus = uint8(stmt.ColumnInt(0))
}
return bonus
}

View File

@ -0,0 +1,513 @@
package main
import (
"encoding/binary"
"eq2emu/internal/common/opcodes"
"eq2emu/internal/udp"
"fmt"
"log"
"time"
)
// LoginRequest represents parsed login request data
type LoginRequest struct {
Username string
Password string
Version uint16
}
// parseLoginRequest parses the login request packet data
func (lc *LoginClient) parseLoginRequest(data []byte) (*LoginRequest, error) {
if len(data) < 10 {
return nil, fmt.Errorf("packet too small")
}
offset := 0
// Skip access code (16-bit string)
accessCodeLen := binary.LittleEndian.Uint16(data[offset:])
offset += 2 + int(accessCodeLen)
// Skip unknown1 (16-bit string)
if offset+2 > len(data) {
return nil, fmt.Errorf("invalid packet format")
}
unknown1Len := binary.LittleEndian.Uint16(data[offset:])
offset += 2 + int(unknown1Len)
// Parse username
if offset+2 > len(data) {
return nil, fmt.Errorf("invalid packet format")
}
usernameLen := binary.LittleEndian.Uint16(data[offset:])
offset += 2
if offset+int(usernameLen) > len(data) {
return nil, fmt.Errorf("invalid username length")
}
username := string(data[offset : offset+int(usernameLen)])
offset += int(usernameLen)
// Parse password
if offset+2 > len(data) {
return nil, fmt.Errorf("invalid packet format")
}
passwordLen := binary.LittleEndian.Uint16(data[offset:])
offset += 2
if offset+int(passwordLen) > len(data) {
return nil, fmt.Errorf("invalid password length")
}
password := string(data[offset : offset+int(passwordLen)])
offset += int(passwordLen)
// Skip unknown fields and get version
version := uint16(0)
if offset+18 <= len(data) { // Skip 4 unknown strings + version
// Simple parsing - in real implementation, parse all unknown fields properly
version = binary.LittleEndian.Uint16(data[len(data)-10:])
}
return &LoginRequest{
Username: username,
Password: password,
Version: version,
}, nil
}
// sendLoginDenied sends login failure response
func (lc *LoginClient) sendLoginDenied() {
data := make([]byte, 12)
data[0] = 1 // reply_code: Invalid username or password
binary.LittleEndian.PutUint32(data[4:], 0xFFFFFFFF)
binary.LittleEndian.PutUint32(data[8:], 0xFFFFFFFF)
packet := &udp.ApplicationPacket{
Opcode: opcodes.OpLoginReplyMsg,
Data: data,
}
lc.connection.SendPacket(packet)
// Disconnect after short delay
time.AfterFunc(1*time.Second, func() {
lc.Disconnect()
})
}
// sendLoginDeniedBadVersion sends bad version response
func (lc *LoginClient) sendLoginDeniedBadVersion() {
data := make([]byte, 12)
data[0] = 6 // reply_code: Version mismatch
binary.LittleEndian.PutUint32(data[4:], 0xFFFFFFFF)
binary.LittleEndian.PutUint32(data[8:], 0xFFFFFFFF)
packet := &udp.ApplicationPacket{
Opcode: opcodes.OpLoginReplyMsg,
Data: data,
}
lc.connection.SendPacket(packet)
time.AfterFunc(1*time.Second, func() {
lc.Disconnect()
})
}
// sendLoginAccepted sends successful login response
func (lc *LoginClient) sendLoginAccepted() {
config := lc.server.GetConfig()
// Build login response packet
data := make([]byte, 64) // Base size, will expand as needed
offset := 0
// Account ID
binary.LittleEndian.PutUint32(data[offset:], uint32(lc.account.ID))
offset += 4
// Login response code (0 = success)
data[offset] = 0
offset++
// Do not force SOGA flag
data[offset] = 1
offset++
// Subscription level
binary.LittleEndian.PutUint32(data[offset:], config.DefaultSubscriptionLevel)
offset += 4
// Race flags (enabled races)
binary.LittleEndian.PutUint32(data[offset:], 0x1FFFFF)
offset += 4
// Class flags (enabled classes)
binary.LittleEndian.PutUint32(data[offset:], 0x7FFFFFE)
offset += 4
// Username (16-bit string)
username := lc.account.Username
binary.LittleEndian.PutUint16(data[offset:], uint16(len(username)))
offset += 2
copy(data[offset:], username)
offset += len(username)
// Expansion flags
binary.LittleEndian.PutUint16(data[offset:], config.ExpansionFlag)
offset += 2
// Additional flags
data[offset] = 0xFF
data[offset+1] = 0xFF
data[offset+2] = 0xFF
offset += 3
// Class access flag
data[offset] = 0xFF
offset++
// Enabled races
binary.LittleEndian.PutUint32(data[offset:], config.EnabledRaces)
offset += 4
// Cities flag
data[offset] = config.CitiesFlag
offset++
packet := &udp.ApplicationPacket{
Opcode: opcodes.OpLoginReplyMsg,
Data: data[:offset],
}
lc.connection.SendPacket(packet)
}
// sendWorldList sends available world servers to client
func (lc *LoginClient) sendWorldList() {
worlds := lc.server.worldList.GetActiveWorlds()
// Build world list packet
data := make([]byte, 0, 1024)
// Number of worlds
worldCount := uint8(len(worlds))
data = append(data, worldCount)
for _, world := range worlds {
// World ID
worldID := make([]byte, 4)
binary.LittleEndian.PutUint32(worldID, uint32(world.ID))
data = append(data, worldID...)
// World name (16-bit string)
nameLen := make([]byte, 2)
binary.LittleEndian.PutUint16(nameLen, uint16(len(world.Name)))
data = append(data, nameLen...)
data = append(data, []byte(world.Name)...)
// World status flags
var flags uint8
if world.Online {
flags |= 0x01
}
if world.Locked {
flags |= 0x02
}
if world.Hidden {
flags |= 0x04
}
data = append(data, flags)
// Population (0-3, where 3 = full)
data = append(data, world.PopulationLevel)
}
packet := &udp.ApplicationPacket{
Opcode: opcodes.OpWorldListMsg,
Data: data,
}
lc.connection.SendPacket(packet)
}
// sendCharacterList sends character list to client
func (lc *LoginClient) sendCharacterList() {
if lc.account == nil {
return
}
data := make([]byte, 0, 2048)
// Number of characters
charCount := uint8(len(lc.account.Characters))
data = append(data, charCount)
// Character data
for _, char := range lc.account.Characters {
if char.Deleted {
continue
}
// Character ID
charID := make([]byte, 4)
binary.LittleEndian.PutUint32(charID, uint32(char.ID))
data = append(data, charID...)
// Server ID
serverID := make([]byte, 4)
binary.LittleEndian.PutUint32(serverID, uint32(char.ServerID))
data = append(data, serverID...)
// Character name (16-bit string)
nameLen := make([]byte, 2)
binary.LittleEndian.PutUint16(nameLen, uint16(len(char.Name)))
data = append(data, nameLen...)
data = append(data, []byte(char.Name)...)
// Character stats
data = append(data, byte(char.Race))
data = append(data, byte(char.Gender))
data = append(data, byte(char.Class))
data = append(data, byte(char.Level))
// Creation timestamp
timestamp := make([]byte, 4)
binary.LittleEndian.PutUint32(timestamp, uint32(char.CreatedDate.Unix()))
data = append(data, timestamp...)
}
// Account info
accountID := make([]byte, 4)
binary.LittleEndian.PutUint32(accountID, uint32(lc.account.ID))
data = append(data, accountID...)
// Max characters
data = append(data, 0xFF, 0xFF, 0xFF, 0xFF) // unknown1
data = append(data, 0x00, 0x00) // unknown2
data = append(data, 0x07, 0x00, 0x00, 0x00) // max chars (7)
data = append(data, 0x00) // unknown4
packet := &udp.ApplicationPacket{
Opcode: opcodes.OpAllCharactersDescReplyMsg,
Data: data,
}
lc.connection.SendPacket(packet)
}
// handleCharacterCreateRequest processes character creation
func (lc *LoginClient) handleCharacterCreateRequest(packet *udp.ApplicationPacket) {
if !lc.authenticated {
lc.Disconnect()
return
}
// Parse character creation data (simplified)
if len(packet.Data) < 20 {
lc.sendCharacterCreateFailed(1) // Generic error
return
}
// Extract basic info (this would need full parsing in real implementation)
serverID := binary.LittleEndian.Uint32(packet.Data[0:4])
nameLen := binary.LittleEndian.Uint16(packet.Data[4:6])
name := string(packet.Data[6 : 6+nameLen])
// Validate character name
if len(name) < 3 || len(name) > 20 {
lc.sendCharacterCreateFailed(9) // Bad name length
return
}
// Check if name is taken
exists, err := lc.server.database.CharacterNameExists(name, int32(serverID))
if err != nil {
log.Printf("Error checking character name: %v", err)
lc.sendCharacterCreateFailed(1)
return
}
if exists {
lc.sendCharacterCreateFailed(12) // Name taken
return
}
// Create character in database
char := &Character{
AccountID: lc.account.ID,
ServerID: int32(serverID),
Name: name,
Level: 1,
Race: 1, // Would be parsed from packet
Gender: 1, // Would be parsed from packet
Class: 1, // Would be parsed from packet
CreatedDate: time.Now(),
}
charID, err := lc.server.database.CreateCharacter(char)
if err != nil {
log.Printf("Error creating character: %v", err)
lc.sendCharacterCreateFailed(1)
return
}
char.ID = charID
lc.account.Characters = append(lc.account.Characters, char)
lc.sendCharacterCreateSuccess(char)
lc.sendCharacterList() // Refresh character list
}
// sendCharacterCreateSuccess sends successful character creation response
func (lc *LoginClient) sendCharacterCreateSuccess(char *Character) {
data := make([]byte, 64)
offset := 0
// Account ID
binary.LittleEndian.PutUint32(data[offset:], uint32(lc.account.ID))
offset += 4
// Response code (0 = success)
binary.LittleEndian.PutUint32(data[offset:], 0)
offset += 4
// Character name
nameLen := uint16(len(char.Name))
binary.LittleEndian.PutUint16(data[offset:], nameLen)
offset += 2
copy(data[offset:], char.Name)
offset += int(nameLen)
packet := &udp.ApplicationPacket{
Opcode: opcodes.OpCreateCharacterReplyMsg,
Data: data[:offset],
}
lc.connection.SendPacket(packet)
}
// sendCharacterCreateFailed sends character creation failure response
func (lc *LoginClient) sendCharacterCreateFailed(reason uint8) {
data := make([]byte, 16)
binary.LittleEndian.PutUint32(data[0:], uint32(lc.account.ID))
data[4] = reason
packet := &udp.ApplicationPacket{
Opcode: opcodes.OpCreateCharacterReplyMsg,
Data: data,
}
lc.connection.SendPacket(packet)
}
// handleCharacterDeleteRequest processes character deletion
func (lc *LoginClient) handleCharacterDeleteRequest(packet *udp.ApplicationPacket) {
if !lc.authenticated {
lc.Disconnect()
return
}
// Parse deletion request (simplified)
if len(packet.Data) < 12 {
return
}
charID := binary.LittleEndian.Uint32(packet.Data[0:4])
serverID := binary.LittleEndian.Uint32(packet.Data[4:8])
// Verify character belongs to this account
var char *Character
for _, c := range lc.account.Characters {
if c.ID == int32(charID) && c.ServerID == int32(serverID) {
char = c
break
}
}
if char == nil {
log.Printf("Account %d attempted to delete character %d that doesn't belong to them", lc.account.ID, charID)
return
}
// Mark character as deleted
err := lc.server.database.DeleteCharacter(int32(charID), lc.account.ID)
if err != nil {
log.Printf("Error deleting character: %v", err)
return
}
char.Deleted = true
// Send deletion response
data := make([]byte, 24)
data[0] = 1 // Success
binary.LittleEndian.PutUint32(data[4:], serverID)
binary.LittleEndian.PutUint32(data[8:], charID)
binary.LittleEndian.PutUint32(data[12:], uint32(lc.account.ID))
packet = &udp.ApplicationPacket{
Opcode: opcodes.OpDeleteCharacterReplyMsg,
Data: data,
}
lc.connection.SendPacket(packet)
lc.sendCharacterList() // Refresh character list
}
// handlePlayCharacterRequest processes character selection for gameplay
func (lc *LoginClient) handlePlayCharacterRequest(packet *udp.ApplicationPacket) {
if !lc.authenticated {
lc.Disconnect()
return
}
// Parse play request
if len(packet.Data) < 8 {
lc.sendPlayFailed(1)
return
}
charID := binary.LittleEndian.Uint32(packet.Data[0:4])
serverID := binary.LittleEndian.Uint32(packet.Data[4:8])
// Find world server
world := lc.server.worldList.GetWorld(int32(serverID))
if world == nil || !world.Online {
lc.sendPlayFailed(2) // Server unavailable
return
}
// Verify character ownership
var char *Character
for _, c := range lc.account.Characters {
if c.ID == int32(charID) && c.ServerID == int32(serverID) {
char = c
break
}
}
if char == nil {
lc.sendPlayFailed(1) // Character not found
return
}
lc.pendingPlayCharID = int32(charID)
// Send play request to world server
err := lc.server.worldList.SendPlayRequest(world, lc.account.ID, int32(charID))
if err != nil {
log.Printf("Error sending play request to world server: %v", err)
lc.sendPlayFailed(1)
return
}
// World server will respond with connection details
log.Printf("Account %s requesting to play character %s on server %s",
lc.account.Username, char.Name, world.Name)
}
// sendPlayFailed sends play character failure response
func (lc *LoginClient) sendPlayFailed(reason uint8) {
data := make([]byte, 16)
data[0] = reason
binary.LittleEndian.PutUint32(data[4:], uint32(lc.account.ID))
packet := &udp.ApplicationPacket{
Opcode: opcodes.OpPlayCharacterReplyMsg,
Data: data,
}
lc.connection.SendPacket(packet)
}

View File

@ -0,0 +1,208 @@
package main
import (
"eq2emu/internal/udp"
"fmt"
"log"
"sync"
"time"
)
// LoginServer manages the main login server functionality
type LoginServer struct {
config *Config
server *udp.Server
database *Database
worldList *WorldList
webServer *WebServer
clients map[string]*LoginClient
clientMutex sync.RWMutex
// Statistics
stats struct {
ConnectionCount int32
LoginAttempts int32
SuccessfulLogins int32
StartTime time.Time
}
}
// NewLoginServer creates a new login server instance
func NewLoginServer(config *Config) (*LoginServer, error) {
ls := &LoginServer{
config: config,
clients: make(map[string]*LoginClient),
}
ls.stats.StartTime = time.Now()
// Initialize database
db, err := NewDatabase(config.Database)
if err != nil {
return nil, fmt.Errorf("database initialization failed: %w", err)
}
ls.database = db
// Initialize world list
ls.worldList = NewWorldList(db)
// Create UDP server with login packet handler
udpConfig := udp.DefaultConfig()
udpConfig.MaxConnections = config.MaxConnections
udpConfig.Timeout = time.Duration(config.TimeoutSeconds) * time.Second
udpConfig.EnableCompression = config.EnableCompression
udpConfig.EnableEncryption = config.EnableEncryption
server, err := udp.NewServer(fmt.Sprintf(":%d", config.Port), ls.handlePacket, udpConfig)
if err != nil {
return nil, fmt.Errorf("UDP server creation failed: %w", err)
}
ls.server = server
// Initialize web server if configured
if config.WebServer.Enabled {
webServer, err := NewWebServer(config.WebServer, ls)
if err != nil {
log.Printf("Web server initialization failed: %v", err)
} else {
ls.webServer = webServer
}
}
return ls, nil
}
// Start begins accepting connections and processing packets
func (ls *LoginServer) Start() error {
log.Println("Starting login server components...")
// Start world list monitoring
go ls.worldList.Start()
// Start web server if configured
if ls.webServer != nil {
go ls.webServer.Start()
}
// Start UDP server
return ls.server.Start()
}
// Stop gracefully shuts down the server
func (ls *LoginServer) Stop() {
log.Println("Stopping login server...")
if ls.webServer != nil {
ls.webServer.Stop()
}
ls.worldList.Stop()
ls.server.Stop()
ls.database.Close()
}
// handlePacket processes incoming packets from clients
func (ls *LoginServer) handlePacket(conn *udp.Connection, packet *udp.ApplicationPacket) {
clientKey := conn.GetSessionID()
// Get or create client
ls.clientMutex.Lock()
client, exists := ls.clients[fmt.Sprintf("%d", clientKey)]
if !exists {
client = NewLoginClient(conn, ls)
ls.clients[fmt.Sprintf("%d", clientKey)] = client
}
ls.clientMutex.Unlock()
// Process packet
client.ProcessPacket(packet)
}
// RemoveClient removes a client from the active clients list
func (ls *LoginServer) RemoveClient(sessionID string) {
ls.clientMutex.Lock()
delete(ls.clients, sessionID)
ls.clientMutex.Unlock()
}
// UpdateStats updates server statistics
func (ls *LoginServer) UpdateStats() {
ls.clientMutex.RLock()
ls.stats.ConnectionCount = int32(len(ls.clients))
ls.clientMutex.RUnlock()
// Update world server statistics
ls.worldList.UpdateStats()
// Clean up old database entries
ls.database.CleanupOldEntries()
log.Printf("Stats: %d connections, %d login attempts, %d successful logins",
ls.stats.ConnectionCount, ls.stats.LoginAttempts, ls.stats.SuccessfulLogins)
}
// CleanupStaleConnections removes inactive connections
func (ls *LoginServer) CleanupStaleConnections() {
var staleClients []string
ls.clientMutex.RLock()
for sessionID, client := range ls.clients {
if client.IsStale() {
staleClients = append(staleClients, sessionID)
}
}
ls.clientMutex.RUnlock()
ls.clientMutex.Lock()
for _, sessionID := range staleClients {
if client, exists := ls.clients[sessionID]; exists {
client.Disconnect()
delete(ls.clients, sessionID)
}
}
ls.clientMutex.Unlock()
if len(staleClients) > 0 {
log.Printf("Cleaned up %d stale connections", len(staleClients))
}
}
// GetStats returns current server statistics
func (ls *LoginServer) GetStats() map[string]any {
ls.clientMutex.RLock()
connectionCount := len(ls.clients)
ls.clientMutex.RUnlock()
return map[string]any{
"connection_count": connectionCount,
"login_attempts": ls.stats.LoginAttempts,
"successful_logins": ls.stats.SuccessfulLogins,
"uptime_seconds": int(time.Since(ls.stats.StartTime).Seconds()),
"world_server_count": ls.worldList.GetActiveCount(),
}
}
// IncrementLoginAttempts atomically increments login attempt counter
func (ls *LoginServer) IncrementLoginAttempts() {
ls.stats.LoginAttempts++
}
// IncrementSuccessfulLogins atomically increments successful login counter
func (ls *LoginServer) IncrementSuccessfulLogins() {
ls.stats.SuccessfulLogins++
}
// GetDatabase returns the database instance
func (ls *LoginServer) GetDatabase() *Database {
return ls.database
}
// GetWorldList returns the world list instance
func (ls *LoginServer) GetWorldList() *WorldList {
return ls.worldList
}
// GetConfig returns the server configuration
func (ls *LoginServer) GetConfig() *Config {
return ls.config
}

74
cmd/login_server/main.go Normal file
View File

@ -0,0 +1,74 @@
package main
import (
"log"
"os"
"os/signal"
"syscall"
"time"
)
var (
loginServer *LoginServer
runLoops = true
)
func main() {
// Initialize logging
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("EQ2Emulator Login Server Starting...")
// Load configuration
config, err := LoadConfig("login_config.json")
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Initialize login server
loginServer, err = NewLoginServer(config)
if err != nil {
log.Fatalf("Failed to create login server: %v", err)
}
// Setup signal handling
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Start the server
go func() {
if err := loginServer.Start(); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}()
// Start maintenance routines
go startMaintenanceTimers()
log.Printf("Login server listening on port %d", config.Port)
// Wait for shutdown signal
<-sigChan
log.Println("Shutdown signal received, stopping server...")
runLoops = false
loginServer.Stop()
log.Println("Server stopped.")
}
// startMaintenanceTimers starts periodic maintenance tasks
func startMaintenanceTimers() {
statsTicker := time.NewTicker(60 * time.Second)
cleanupTicker := time.NewTicker(5 * time.Minute)
defer statsTicker.Stop()
defer cleanupTicker.Stop()
for runLoops {
select {
case <-statsTicker.C:
loginServer.UpdateStats()
case <-cleanupTicker.C:
loginServer.CleanupStaleConnections()
}
}
}

143
cmd/login_server/web.go Normal file
View File

@ -0,0 +1,143 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
// WebServer provides HTTP endpoints for monitoring
type WebServer struct {
config WebServerConfig
loginServer *LoginServer
server *http.Server
}
// NewWebServer creates a new web monitoring server
func NewWebServer(config WebServerConfig, loginServer *LoginServer) (*WebServer, error) {
ws := &WebServer{
config: config,
loginServer: loginServer,
}
mux := http.NewServeMux()
mux.HandleFunc("/status", ws.handleStatus)
mux.HandleFunc("/worlds", ws.handleWorlds)
mux.HandleFunc("/stats", ws.handleStats)
mux.HandleFunc("/health", ws.handleHealth)
ws.server = &http.Server{
Addr: fmt.Sprintf("%s:%d", config.Address, config.Port),
Handler: ws.basicAuth(mux),
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
return ws, nil
}
// Start begins the web server
func (ws *WebServer) Start() {
log.Printf("Starting web server on %s", ws.server.Addr)
var err error
if ws.config.CertFile != "" && ws.config.KeyFile != "" {
err = ws.server.ListenAndServeTLS(ws.config.CertFile, ws.config.KeyFile)
} else {
err = ws.server.ListenAndServe()
}
if err != http.ErrServerClosed {
log.Printf("Web server error: %v", err)
}
}
// Stop shuts down the web server
func (ws *WebServer) Stop() {
if ws.server != nil {
ws.server.Close()
}
}
// basicAuth provides basic HTTP authentication
func (ws *WebServer) basicAuth(next http.Handler) http.Handler {
if ws.config.Username == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok || username != ws.config.Username || password != ws.config.Password {
w.Header().Set("WWW-Authenticate", `Basic realm="Login Server"`)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
return
}
next.ServeHTTP(w, r)
})
}
// handleStatus returns server status information
func (ws *WebServer) handleStatus(w http.ResponseWriter, r *http.Request) {
stats := ws.loginServer.GetStats()
status := map[string]any{
"service": "eq2emu-login-server",
"version": "1.0.0",
"status": "running",
"timestamp": time.Now().UTC(),
"statistics": stats,
}
ws.writeJSON(w, status)
}
// handleWorlds returns world server information
func (ws *WebServer) handleWorlds(w http.ResponseWriter, r *http.Request) {
worlds := ws.loginServer.worldList.GetActiveWorlds()
worldStats := ws.loginServer.worldList.GetStats()
response := map[string]any{
"world_servers": worlds,
"statistics": worldStats,
}
ws.writeJSON(w, response)
}
// handleStats returns detailed server statistics
func (ws *WebServer) handleStats(w http.ResponseWriter, r *http.Request) {
serverStats := ws.loginServer.GetStats()
worldStats := ws.loginServer.worldList.GetStats()
stats := map[string]any{
"server": serverStats,
"worlds": worldStats,
"timestamp": time.Now().UTC(),
}
ws.writeJSON(w, stats)
}
// handleHealth returns basic health check
func (ws *WebServer) handleHealth(w http.ResponseWriter, r *http.Request) {
health := map[string]any{
"status": "healthy",
"timestamp": time.Now().UTC(),
}
ws.writeJSON(w, health)
}
// writeJSON writes JSON response with proper headers
func (ws *WebServer) writeJSON(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Printf("JSON encoding error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

View File

@ -0,0 +1,356 @@
package main
import (
"fmt"
"log"
"sync"
"time"
)
// WorldServer represents a game world server
type WorldServer struct {
ID int32
Name string
Description string
IPAddress string
Port int
Status string
Population int32
PopulationLevel uint8
Locked bool
Hidden bool
Online bool
CreatedDate time.Time
LastUpdate time.Time
}
// WorldServerStats holds runtime statistics
type WorldServerStats struct {
Population int32
ZonesActive int32
PlayersOnline int32
UptimeSeconds int64
}
// WorldList manages all world servers
type WorldList struct {
servers map[int32]*WorldServer
mutex sync.RWMutex
database *Database
updateTicker *time.Ticker
stopChan chan struct{}
}
// NewWorldList creates a new world list manager
func NewWorldList(database *Database) *WorldList {
return &WorldList{
servers: make(map[int32]*WorldServer),
database: database,
stopChan: make(chan struct{}),
}
}
// Start begins world server monitoring
func (wl *WorldList) Start() {
log.Println("Starting world list monitoring...")
// Load world servers from database
if err := wl.LoadFromDatabase(); err != nil {
log.Printf("Failed to load world servers: %v", err)
}
// Start periodic updates
wl.updateTicker = time.NewTicker(30 * time.Second)
go func() {
for {
select {
case <-wl.updateTicker.C:
wl.UpdateStats()
case <-wl.stopChan:
return
}
}
}()
}
// Stop shuts down world server monitoring
func (wl *WorldList) Stop() {
log.Println("Stopping world list monitoring...")
if wl.updateTicker != nil {
wl.updateTicker.Stop()
}
close(wl.stopChan)
}
// LoadFromDatabase loads world servers from the database
func (wl *WorldList) LoadFromDatabase() error {
servers, err := wl.database.GetWorldServers()
if err != nil {
return fmt.Errorf("failed to load world servers: %w", err)
}
wl.mutex.Lock()
defer wl.mutex.Unlock()
// Clear existing servers
wl.servers = make(map[int32]*WorldServer)
// Add loaded servers
for _, server := range servers {
wl.servers[server.ID] = server
log.Printf("Loaded world server: %s (ID: %d)", server.Name, server.ID)
}
log.Printf("Loaded %d world servers", len(servers))
return nil
}
// GetActiveWorlds returns all online world servers
func (wl *WorldList) GetActiveWorlds() []*WorldServer {
wl.mutex.RLock()
defer wl.mutex.RUnlock()
var active []*WorldServer
for _, server := range wl.servers {
if server.Online && !server.Hidden {
active = append(active, server)
}
}
return active
}
// GetWorld returns a specific world server by ID
func (wl *WorldList) GetWorld(id int32) *WorldServer {
wl.mutex.RLock()
defer wl.mutex.RUnlock()
return wl.servers[id]
}
// GetActiveCount returns the number of online world servers
func (wl *WorldList) GetActiveCount() int {
wl.mutex.RLock()
defer wl.mutex.RUnlock()
count := 0
for _, server := range wl.servers {
if server.Online {
count++
}
}
return count
}
// UpdateServerStatus updates a world server's status
func (wl *WorldList) UpdateServerStatus(id int32, online bool, population int32, locked bool) {
wl.mutex.Lock()
defer wl.mutex.Unlock()
server, exists := wl.servers[id]
if !exists {
log.Printf("Attempted to update unknown server ID: %d", id)
return
}
server.Online = online
server.Population = population
server.Locked = locked
server.LastUpdate = time.Now()
// Update population level
server.PopulationLevel = wl.calculatePopulationLevel(population)
if online {
server.Status = "online"
} else {
server.Status = "offline"
}
log.Printf("Updated server %s: online=%t, population=%d, locked=%t",
server.Name, online, population, locked)
}
// calculatePopulationLevel converts population to display level
func (wl *WorldList) calculatePopulationLevel(population int32) uint8 {
switch {
case population >= 1000:
return 3 // Full
case population >= 500:
return 2 // High
case population >= 100:
return 1 // Medium
default:
return 0 // Low
}
}
// UpdateStats updates world server statistics
func (wl *WorldList) UpdateStats() {
wl.mutex.RLock()
servers := make([]*WorldServer, 0, len(wl.servers))
for _, server := range wl.servers {
servers = append(servers, server)
}
wl.mutex.RUnlock()
// Update statistics for each server
for _, server := range servers {
if server.Online {
stats := &WorldServerStats{
Population: server.Population,
ZonesActive: 0, // Would be updated by world server
PlayersOnline: server.Population,
UptimeSeconds: int64(time.Since(server.LastUpdate).Seconds()),
}
if err := wl.database.UpdateWorldServerStats(server.ID, stats); err != nil {
log.Printf("Failed to update stats for server %d: %v", server.ID, err)
}
}
}
}
// SendPlayRequest sends a character play request to a world server
func (wl *WorldList) SendPlayRequest(world *WorldServer, accountID, charID int32) error {
// In a real implementation, this would establish communication with the world server
// and send the play request. For now, we'll simulate the response.
log.Printf("Sending play request to world server %s for account %d, character %d",
world.Name, accountID, charID)
// Simulate world server response after a short delay
go func() {
time.Sleep(100 * time.Millisecond)
// For demonstration, we'll always succeed
// In reality, the world server would validate the character and respond
accessKey := generateAccessKey()
// This would normally come from the world server's response
wl.HandlePlayResponse(world.ID, accountID, charID, true,
world.IPAddress, world.Port, accessKey)
}()
return nil
}
// HandlePlayResponse processes a play response from a world server
func (wl *WorldList) HandlePlayResponse(worldID, accountID, charID int32,
success bool, ipAddress string, port int, accessKey int32) {
// Find the client that requested this
// This is simplified - in reality you'd track pending requests
log.Printf("Play response from world %d: success=%t, access_key=%d",
worldID, success, accessKey)
// Send response to appropriate client
// This would need to be implemented with proper client tracking
}
// generateAccessKey generates a random access key for world server connections
func generateAccessKey() int32 {
return int32(time.Now().UnixNano() & 0x7FFFFFFF)
}
// AddServer adds a new world server (for dynamic registration)
func (wl *WorldList) AddServer(server *WorldServer) {
wl.mutex.Lock()
defer wl.mutex.Unlock()
wl.servers[server.ID] = server
log.Printf("Added world server: %s (ID: %d)", server.Name, server.ID)
}
// RemoveServer removes a world server
func (wl *WorldList) RemoveServer(id int32) {
wl.mutex.Lock()
defer wl.mutex.Unlock()
if server, exists := wl.servers[id]; exists {
delete(wl.servers, id)
log.Printf("Removed world server: %s (ID: %d)", server.Name, id)
}
}
// GetServerList returns a formatted server list for client packets
func (wl *WorldList) GetServerList() []byte {
wl.mutex.RLock()
defer wl.mutex.RUnlock()
// Build server list packet data
data := make([]byte, 0, 1024)
// Count active servers
activeCount := 0
for _, server := range wl.servers {
if !server.Hidden {
activeCount++
}
}
// Add server count
data = append(data, byte(activeCount))
// Add server data
for _, server := range wl.servers {
if server.Hidden {
continue
}
// Server ID (4 bytes)
serverIDBytes := make([]byte, 4)
serverIDBytes[0] = byte(server.ID)
serverIDBytes[1] = byte(server.ID >> 8)
serverIDBytes[2] = byte(server.ID >> 16)
serverIDBytes[3] = byte(server.ID >> 24)
data = append(data, serverIDBytes...)
// Server name (null-terminated)
data = append(data, []byte(server.Name)...)
data = append(data, 0)
// Server flags
var flags byte
if server.Online {
flags |= 0x01
}
if server.Locked {
flags |= 0x02
}
data = append(data, flags)
// Population level
data = append(data, server.PopulationLevel)
}
return data
}
// GetStats returns world list statistics
func (wl *WorldList) GetStats() map[string]any {
wl.mutex.RLock()
defer wl.mutex.RUnlock()
totalServers := len(wl.servers)
onlineServers := 0
totalPopulation := int32(0)
for _, server := range wl.servers {
if server.Online {
onlineServers++
totalPopulation += server.Population
}
}
return map[string]any{
"total_servers": totalServers,
"online_servers": onlineServers,
"offline_servers": totalServers - onlineServers,
"total_population": totalPopulation,
}
}

16
go.mod
View File

@ -1,3 +1,19 @@
module eq2emu
go 1.24.5
require (
github.com/dustin/go-humanize v1.0.1 // indirect
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
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sys v0.34.0 // indirect
modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.37.1 // indirect
zombiezen.com/go/sqlite v1.4.2 // indirect
)

27
go.sum Normal file
View File

@ -0,0 +1,27 @@
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo=
zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc=