first pass on login server
This commit is contained in:
parent
555e2aaa8f
commit
82d03c2624
230
cmd/login_server/client.go
Normal file
230
cmd/login_server/client.go
Normal 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
163
cmd/login_server/config.go
Normal 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
|
||||
}
|
395
cmd/login_server/database.go
Normal file
395
cmd/login_server/database.go
Normal 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
|
||||
}
|
513
cmd/login_server/handlers.go
Normal file
513
cmd/login_server/handlers.go
Normal 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)
|
||||
}
|
208
cmd/login_server/login_server.go
Normal file
208
cmd/login_server/login_server.go
Normal 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
74
cmd/login_server/main.go
Normal 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
143
cmd/login_server/web.go
Normal 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)
|
||||
}
|
||||
}
|
356
cmd/login_server/world_server.go
Normal file
356
cmd/login_server/world_server.go
Normal 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
16
go.mod
@ -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
27
go.sum
Normal 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=
|
Loading…
x
Reference in New Issue
Block a user