mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +01:00
An MMO server without multiplayer defeats the purpose. PostgreSQL is the right choice and Docker Compose already solves the setup pain. This reverts the common/db wrapper, SQLite schema, config Driver field, modernc.org/sqlite dependency, and all repo type changes while keeping the dashboard, wizard, and CI improvements from the previous commit.
405 lines
10 KiB
Go
405 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
cfg "erupe-ce/config"
|
|
"flag"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"syscall"
|
|
"time"
|
|
|
|
"erupe-ce/common/gametime"
|
|
"erupe-ce/server/api"
|
|
"erupe-ce/server/channelserver"
|
|
"erupe-ce/server/discordbot"
|
|
"erupe-ce/server/entranceserver"
|
|
"erupe-ce/server/migrations"
|
|
"erupe-ce/server/setup"
|
|
"erupe-ce/server/signserver"
|
|
"strings"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Temporary DB auto clean on startup for quick development & testing.
|
|
func cleanDB(db *sqlx.DB) {
|
|
_ = db.MustExec("DELETE FROM guild_characters")
|
|
_ = db.MustExec("DELETE FROM guilds")
|
|
_ = db.MustExec("DELETE FROM characters")
|
|
_ = db.MustExec("DELETE FROM users")
|
|
}
|
|
|
|
var Commit = func() string {
|
|
if info, ok := debug.ReadBuildInfo(); ok {
|
|
for _, setting := range info.Settings {
|
|
if setting.Key == "vcs.revision" {
|
|
return setting.Value[:7]
|
|
}
|
|
}
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func setupDiscordBot(config *cfg.Config, logger *zap.Logger) *discordbot.DiscordBot {
|
|
bot, err := discordbot.NewDiscordBot(discordbot.Options{
|
|
Logger: logger,
|
|
Config: config,
|
|
})
|
|
|
|
if err != nil {
|
|
preventClose(config, fmt.Sprintf("Discord: Failed to start, %s", err.Error()))
|
|
}
|
|
|
|
// Discord bot
|
|
err = bot.Start()
|
|
|
|
if err != nil {
|
|
preventClose(config, fmt.Sprintf("Discord: Failed to start, %s", err.Error()))
|
|
}
|
|
|
|
if err = bot.RegisterCommands(); err != nil {
|
|
preventClose(config, fmt.Sprintf("Discord: Failed to start, %s", err.Error()))
|
|
}
|
|
|
|
return bot
|
|
}
|
|
|
|
func main() {
|
|
runSetup := flag.Bool("setup", false, "Launch the setup wizard (even if config.json exists)")
|
|
flag.Parse()
|
|
|
|
var err error
|
|
|
|
var zapLogger *zap.Logger
|
|
zapLogger, _ = zap.NewDevelopment()
|
|
|
|
defer func() { _ = zapLogger.Sync() }()
|
|
logger := zapLogger.Named("main")
|
|
|
|
if *runSetup {
|
|
logger.Info("Launching setup wizard (--setup)")
|
|
if err := setup.Run(logger.Named("setup"), 8080); err != nil {
|
|
logger.Fatal("Setup wizard failed", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
config, cfgErr := cfg.LoadConfig()
|
|
if cfgErr != nil {
|
|
if _, err := os.Stat("config.json"); os.IsNotExist(err) {
|
|
logger.Info("No config.json found, launching setup wizard")
|
|
if err := setup.Run(logger.Named("setup"), 8080); err != nil {
|
|
logger.Fatal("Setup wizard failed", zap.Error(err))
|
|
}
|
|
config, cfgErr = cfg.LoadConfig()
|
|
if cfgErr != nil {
|
|
logger.Fatal("Config still invalid after setup", zap.Error(cfgErr))
|
|
}
|
|
} else {
|
|
preventClose(config, fmt.Sprintf("Failed to load config: %s", cfgErr.Error()))
|
|
}
|
|
}
|
|
|
|
logger.Info(fmt.Sprintf("Starting Erupe (9.3b-%s)", Commit()))
|
|
logger.Info(fmt.Sprintf("Client Mode: %s (%d)", config.ClientMode, config.RealClientMode))
|
|
|
|
if config.Database.Password == "" {
|
|
preventClose(config, "Database password is blank")
|
|
}
|
|
|
|
if net.ParseIP(config.Host) == nil {
|
|
ips, _ := net.LookupIP(config.Host)
|
|
for _, ip := range ips {
|
|
if ip != nil {
|
|
config.Host = ip.String()
|
|
break
|
|
}
|
|
}
|
|
if net.ParseIP(config.Host) == nil {
|
|
preventClose(config, "Invalid host address")
|
|
}
|
|
}
|
|
|
|
// Discord bot
|
|
var discordBot *discordbot.DiscordBot = nil
|
|
|
|
if config.Discord.Enabled {
|
|
discordBot = setupDiscordBot(config, logger)
|
|
|
|
logger.Info("Discord: Started successfully")
|
|
} else {
|
|
logger.Info("Discord: Disabled")
|
|
}
|
|
|
|
// Create the postgres DB pool.
|
|
connectString := fmt.Sprintf(
|
|
"host='%s' port='%d' user='%s' password='%s' dbname='%s' sslmode=disable",
|
|
config.Database.Host,
|
|
config.Database.Port,
|
|
config.Database.User,
|
|
config.Database.Password,
|
|
config.Database.Database,
|
|
)
|
|
|
|
db, err := sqlx.Open("postgres", connectString)
|
|
if err != nil {
|
|
preventClose(config, fmt.Sprintf("Database: Failed to open, %s", err.Error()))
|
|
}
|
|
|
|
// Test the DB connection.
|
|
err = db.Ping()
|
|
if err != nil {
|
|
preventClose(config, fmt.Sprintf("Database: Failed to ping, %s", err.Error()))
|
|
}
|
|
|
|
// Configure connection pool to avoid exhausting PostgreSQL under load.
|
|
db.SetMaxOpenConns(50)
|
|
db.SetMaxIdleConns(10)
|
|
db.SetConnMaxLifetime(5 * time.Minute)
|
|
db.SetConnMaxIdleTime(2 * time.Minute)
|
|
|
|
logger.Info("Database: Started successfully")
|
|
|
|
// Run database migrations
|
|
verBefore, _ := migrations.Version(db)
|
|
applied, migErr := migrations.Migrate(db, logger.Named("migrations"))
|
|
if migErr != nil {
|
|
preventClose(config, fmt.Sprintf("Database migration failed: %s", migErr.Error()))
|
|
}
|
|
if applied > 0 {
|
|
ver, _ := migrations.Version(db)
|
|
logger.Info(fmt.Sprintf("Database: Applied %d migration(s), now at version %d", applied, ver))
|
|
}
|
|
|
|
// Auto-apply seed data on a fresh database so users who skip the wizard
|
|
// still get shops, events, and gacha. Seed files use ON CONFLICT DO NOTHING
|
|
// so this is safe to run even if data already exists.
|
|
if verBefore == 0 && applied > 0 {
|
|
seedApplied, seedErr := migrations.ApplySeedData(db, logger.Named("migrations"))
|
|
if seedErr != nil {
|
|
logger.Warn(fmt.Sprintf("Seed data failed: %s", seedErr.Error()))
|
|
} else if seedApplied > 0 {
|
|
logger.Info(fmt.Sprintf("Database: Applied %d seed data file(s)", seedApplied))
|
|
}
|
|
}
|
|
|
|
// Pre-compute all server IDs this instance will own, so we only
|
|
// delete our own rows (safe for multi-instance on the same DB).
|
|
var ownedServerIDs []string
|
|
{
|
|
si := 0
|
|
for _, ee := range config.Entrance.Entries {
|
|
ci := 0
|
|
for range ee.Channels {
|
|
sid := (4096 + si*256) + (16 + ci)
|
|
ownedServerIDs = append(ownedServerIDs, fmt.Sprint(sid))
|
|
ci++
|
|
}
|
|
si++
|
|
}
|
|
}
|
|
|
|
// Clear stale data scoped to this instance's server IDs
|
|
if len(ownedServerIDs) > 0 {
|
|
idList := strings.Join(ownedServerIDs, ",")
|
|
if config.DebugOptions.ProxyPort == 0 {
|
|
_ = db.MustExec("DELETE FROM sign_sessions WHERE server_id IN (" + idList + ")")
|
|
}
|
|
_ = db.MustExec("DELETE FROM servers WHERE server_id IN (" + idList + ")")
|
|
}
|
|
_ = db.MustExec(`UPDATE guild_characters SET treasure_hunt=NULL`)
|
|
|
|
// Clean the DB if the option is on.
|
|
if config.DebugOptions.CleanDB {
|
|
logger.Info("Database: Started clearing...")
|
|
cleanDB(db)
|
|
logger.Info("Database: Finished clearing")
|
|
}
|
|
|
|
logger.Info(fmt.Sprintf("Server Time: %s", gametime.Adjusted().String()))
|
|
|
|
// Warn if quest files are missing — clients crash without them.
|
|
questPath := filepath.Join(config.BinPath, "quests")
|
|
if entries, err := os.ReadDir(questPath); err != nil || len(entries) == 0 {
|
|
logger.Warn("No quest files found in " + questPath)
|
|
logger.Warn("Download quest/scenario files from: https://files.catbox.moe/xf0l7w.7z")
|
|
logger.Warn("Extract into your BinPath directory (default: bin/)")
|
|
logger.Warn("Without these files, quests will not load and clients will crash.")
|
|
}
|
|
|
|
// Now start our server(s).
|
|
|
|
// Entrance server.
|
|
|
|
var entranceServer *entranceserver.Server
|
|
if config.Entrance.Enabled {
|
|
entranceServer = entranceserver.NewServer(
|
|
&entranceserver.Config{
|
|
Logger: logger.Named("entrance"),
|
|
ErupeConfig: config,
|
|
DB: db,
|
|
})
|
|
err = entranceServer.Start()
|
|
if err != nil {
|
|
preventClose(config, fmt.Sprintf("Entrance: Failed to start, %s", err.Error()))
|
|
}
|
|
logger.Info("Entrance: Started successfully")
|
|
} else {
|
|
logger.Info("Entrance: Disabled")
|
|
}
|
|
|
|
// Sign server.
|
|
|
|
var signServer *signserver.Server
|
|
if config.Sign.Enabled {
|
|
signServer = signserver.NewServer(
|
|
&signserver.Config{
|
|
Logger: logger.Named("sign"),
|
|
ErupeConfig: config,
|
|
DB: db,
|
|
})
|
|
err = signServer.Start()
|
|
if err != nil {
|
|
preventClose(config, fmt.Sprintf("Sign: Failed to start, %s", err.Error()))
|
|
}
|
|
logger.Info("Sign: Started successfully")
|
|
} else {
|
|
logger.Info("Sign: Disabled")
|
|
}
|
|
|
|
// New Sign server
|
|
var ApiServer *api.APIServer
|
|
if config.API.Enabled {
|
|
ApiServer = api.NewAPIServer(
|
|
&api.Config{
|
|
Logger: logger.Named("sign"),
|
|
ErupeConfig: config,
|
|
DB: db,
|
|
})
|
|
err = ApiServer.Start()
|
|
if err != nil {
|
|
preventClose(config, fmt.Sprintf("API: Failed to start, %s", err.Error()))
|
|
}
|
|
logger.Info("API: Started successfully")
|
|
} else {
|
|
logger.Info("API: Disabled")
|
|
}
|
|
|
|
var channels []*channelserver.Server
|
|
|
|
if config.Channel.Enabled {
|
|
channelQuery := ""
|
|
si := 0
|
|
ci := 0
|
|
count := 1
|
|
for j, ee := range config.Entrance.Entries {
|
|
for i, ce := range ee.Channels {
|
|
sid := (4096 + si*256) + (16 + ci)
|
|
if !ce.IsEnabled() {
|
|
logger.Info(fmt.Sprintf("Channel %d (%d): Disabled via config", count, ce.Port))
|
|
ci++
|
|
count++
|
|
continue
|
|
}
|
|
c := *channelserver.NewServer(&channelserver.Config{
|
|
ID: uint16(sid),
|
|
Logger: logger.Named("channel-" + fmt.Sprint(count)),
|
|
ErupeConfig: config,
|
|
DB: db,
|
|
DiscordBot: discordBot,
|
|
})
|
|
if ee.IP == "" {
|
|
c.IP = config.Host
|
|
} else {
|
|
c.IP = ee.IP
|
|
}
|
|
c.Port = ce.Port
|
|
c.GlobalID = fmt.Sprintf("%02d%02d", j+1, i+1)
|
|
err = c.Start()
|
|
if err != nil {
|
|
preventClose(config, fmt.Sprintf("Channel: Failed to start, %s", err.Error()))
|
|
} else {
|
|
channelQuery += fmt.Sprintf(
|
|
`INSERT INTO servers (server_id, current_players, world_name, world_description, land) VALUES (%d, 0, '%s', '%s', %d);`,
|
|
sid, ee.Name, ee.Description, i+1,
|
|
)
|
|
channels = append(channels, &c)
|
|
logger.Info(fmt.Sprintf("Channel %d (%d): Started successfully", count, ce.Port))
|
|
count++
|
|
}
|
|
ci++
|
|
}
|
|
ci = 0
|
|
si++
|
|
}
|
|
|
|
// Register all servers in DB
|
|
_ = db.MustExec(channelQuery)
|
|
|
|
registry := channelserver.NewLocalChannelRegistry(channels)
|
|
for _, c := range channels {
|
|
c.Registry = registry
|
|
}
|
|
}
|
|
|
|
logger.Info("Finished starting Erupe")
|
|
|
|
// Wait for exit or interrupt with ctrl+C.
|
|
c := make(chan os.Signal, 1)
|
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
|
<-c
|
|
|
|
if !config.DisableSoftCrash {
|
|
for i := 0; i < 10; i++ {
|
|
message := fmt.Sprintf("Shutting down in %d...", 10-i)
|
|
for _, c := range channels {
|
|
c.BroadcastChatMessage(message)
|
|
}
|
|
logger.Info(message)
|
|
time.Sleep(time.Second)
|
|
}
|
|
}
|
|
|
|
if config.Channel.Enabled {
|
|
for _, c := range channels {
|
|
c.Shutdown()
|
|
}
|
|
}
|
|
|
|
if config.Sign.Enabled {
|
|
signServer.Shutdown()
|
|
}
|
|
|
|
if config.API.Enabled {
|
|
ApiServer.Shutdown()
|
|
}
|
|
|
|
if config.Entrance.Enabled {
|
|
entranceServer.Shutdown()
|
|
}
|
|
|
|
time.Sleep(1 * time.Second)
|
|
}
|
|
|
|
func wait() {
|
|
for {
|
|
time.Sleep(time.Millisecond * 100)
|
|
}
|
|
}
|
|
|
|
func preventClose(config *cfg.Config, text string) {
|
|
if config != nil && config.DisableSoftCrash {
|
|
os.Exit(0)
|
|
}
|
|
fmt.Println("\nFailed to start Erupe:\n" + text)
|
|
go wait()
|
|
fmt.Println("\nPress Enter/Return to exit...")
|
|
_, _ = fmt.Scanln()
|
|
os.Exit(0)
|
|
}
|