mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-05-06 14:24:15 +02:00
Brings 53 develop commits (i18n, Diva, campaign, guild invites, save transfer, return/rookie guilds, hunting tournament, JSON quest/scenario loaders, Ghidra-derived user binary parsing, and misc fixes) onto main now that 9.3.2 has been tagged and released. Resolves two overlap zones: 1. Migration number collision. Main shipped 0010_fix_zero_rasta_id and 0011_fix_stale_boost_time in 9.3.2; develop had independently numbered 0010_campaign..0015_tournament. The migration runner keys applied versions by integer, so coexisting files with the same numeric prefix would silently skip each other. Develop's files have been renumbered to 0016..0021, leaving main's 0010/0011 intact. A schema_version rename script is required on any server that had already applied the old develop numbers (only frontier.mogapedia.fr at the time of this merge). 2. CHANGELOG.md. Develop's in-progress feature entries move into [Unreleased] with updated migration references; the [9.3.2] section is preserved verbatim. main.go version string bumped to 9.4.0-dev to mark the new cycle. Full test suite (go test -race ./...) passes.
463 lines
12 KiB
Go
463 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
cfg "erupe-ce/config"
|
|
"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"
|
|
|
|
"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.4.0-dev-%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 connection to %s:%d\nError: %s",
|
|
config.Database.Host, config.Database.Port, err.Error(),
|
|
))
|
|
}
|
|
|
|
// Test the DB connection.
|
|
err = db.Ping()
|
|
if err != nil {
|
|
dbAddr := fmt.Sprintf("%s:%d", config.Database.Host, config.Database.Port)
|
|
var hint string
|
|
var pqErr *pq.Error
|
|
var netErr *net.OpError
|
|
switch {
|
|
case errors.As(err, &netErr):
|
|
hint = fmt.Sprintf(
|
|
"Database: PostgreSQL is not reachable at %s\n"+
|
|
" Check that PostgreSQL is running: systemctl status postgresql (or docker ps)\n"+
|
|
" Using Docker? Run: cd docker && docker compose up db -d",
|
|
dbAddr,
|
|
)
|
|
case errors.As(err, &pqErr) && pqErr.Code == "28P01":
|
|
hint = fmt.Sprintf(
|
|
"Database: Wrong password for user '%s' at %s\n"+
|
|
" Update Database.Password in config.json to match your PostgreSQL password.",
|
|
config.Database.User, dbAddr,
|
|
)
|
|
case errors.As(err, &pqErr) && pqErr.Code == "3D000":
|
|
hint = fmt.Sprintf(
|
|
"Database: Database '%s' does not exist on %s\n"+
|
|
" Create it with: createdb -U %s %s",
|
|
config.Database.Database, dbAddr,
|
|
config.Database.User, config.Database.Database,
|
|
)
|
|
default:
|
|
hint = fmt.Sprintf("Database: Failed to connect to %s — %s", dbAddr, err.Error())
|
|
}
|
|
preventClose(config, hint)
|
|
}
|
|
|
|
// 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 {
|
|
if _, err := db.Exec("DELETE FROM sign_sessions WHERE server_id IN (" + idList + ")"); err != nil {
|
|
logger.Warn("Failed to clear stale sign sessions", zap.Error(err))
|
|
}
|
|
}
|
|
if _, err := db.Exec("DELETE FROM servers WHERE server_id IN (" + idList + ")"); err != nil {
|
|
logger.Warn("Failed to clear stale server entries", zap.Error(err))
|
|
}
|
|
}
|
|
if _, err := db.Exec(`UPDATE guild_characters SET treasure_hunt=NULL`); err != nil {
|
|
logger.Warn("Failed to reset treasure hunts", zap.Error(err))
|
|
}
|
|
|
|
// 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
|
|
seenPorts := make(map[uint16]string)
|
|
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
|
|
}
|
|
if prev, exists := seenPorts[ce.Port]; exists {
|
|
preventClose(config, fmt.Sprintf("Channel %d: port %d already used by %s", count, ce.Port, prev))
|
|
ci++
|
|
count++
|
|
continue
|
|
}
|
|
seenPorts[ce.Port] = fmt.Sprintf("channel %d", count)
|
|
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.
|
|
sig := make(chan os.Signal, 2)
|
|
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
|
<-sig
|
|
|
|
if !config.DisableSoftCrash {
|
|
countdown := config.ShutdownCountdownSeconds
|
|
if countdown <= 0 {
|
|
countdown = 10
|
|
}
|
|
for i := 0; i < countdown; i++ {
|
|
message := fmt.Sprintf("Shutting down in %d...", countdown-i)
|
|
for _, c := range channels {
|
|
c.BroadcastChatMessage(message)
|
|
}
|
|
logger.Info(message)
|
|
select {
|
|
case <-sig:
|
|
logger.Info("Second signal received, forcing shutdown")
|
|
os.Exit(1)
|
|
case <-time.After(time.Second):
|
|
}
|
|
}
|
|
}
|
|
|
|
if config.Channel.Enabled {
|
|
drainCtx, drainCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer drainCancel()
|
|
for _, c := range channels {
|
|
c.ShutdownAndDrain(drainCtx)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|