Files
Erupe/server/setup/wizard.go
Houmgaor ecfe58ffb4 feat: add SQLite support, setup wizard enhancements, and live dashboard
Add zero-dependency SQLite mode so users can run Erupe without
PostgreSQL. A transparent db.DB wrapper auto-translates PostgreSQL
SQL ($N placeholders, now(), ::casts, ILIKE, public. prefix,
TRUNCATE) for SQLite at runtime — all 28 repo files use the wrapper
with no per-query changes needed.

Setup wizard gains two new steps: quest file detection with download
link, and gameplay presets (solo/small/community/rebalanced). The API
server gets a /dashboard endpoint with auto-refreshing stats.

CI release workflow now builds and pushes Docker images to GHCR
alongside binary artifacts on tag push.

Key changes:
- common/db: DB/Tx wrapper with 6 SQL translation rules
- server/migrations/sqlite: full SQLite schema (0001-0005)
- config: Database.Driver field ("postgres" or "sqlite")
- main.go: SQLite connection with WAL mode, single writer
- server/setup: quest check + preset selection steps
- server/api: /dashboard with live stats
- .github/workflows: Docker in release, deduplicate docker.yml
2026-03-05 18:00:30 +01:00

313 lines
8.8 KiB
Go

package setup
import (
"database/sql"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"github.com/lib/pq"
)
// clientModes returns all supported client version strings.
func clientModes() []string {
return []string{
"S1.0", "S1.5", "S2.0", "S2.5", "S3.0", "S3.5", "S4.0", "S5.0", "S5.5", "S6.0", "S7.0",
"S8.0", "S8.5", "S9.0", "S10", "FW.1", "FW.2", "FW.3", "FW.4", "FW.5", "G1", "G2", "G3",
"G3.1", "G3.2", "GG", "G5", "G5.1", "G5.2", "G6", "G6.1", "G7", "G8", "G8.1", "G9", "G9.1",
"G10", "G10.1", "Z1", "Z2", "ZZ",
}
}
// FinishRequest holds the user's configuration choices from the wizard.
type FinishRequest struct {
DBHost string `json:"dbHost"`
DBPort int `json:"dbPort"`
DBUser string `json:"dbUser"`
DBPassword string `json:"dbPassword"`
DBName string `json:"dbName"`
Host string `json:"host"`
Language string `json:"language"`
ClientMode string `json:"clientMode"`
AutoCreateAccount bool `json:"autoCreateAccount"`
Preset string `json:"preset"`
}
// buildDefaultConfig produces a minimal config map with only user-provided values.
// All other settings are filled by Viper's registered defaults at load time.
func buildDefaultConfig(req FinishRequest) map[string]interface{} {
lang := req.Language
if lang == "" {
lang = "jp"
}
cfg := map[string]interface{}{
"Host": req.Host,
"Language": lang,
"ClientMode": req.ClientMode,
"AutoCreateAccount": req.AutoCreateAccount,
"Database": map[string]interface{}{
"Host": req.DBHost,
"Port": req.DBPort,
"User": req.DBUser,
"Password": req.DBPassword,
"Database": req.DBName,
},
}
// Apply preset overrides. The "community" preset uses Viper defaults and
// adds nothing to the config file.
if overrides, ok := presetConfigs()[req.Preset]; ok {
for k, v := range overrides {
cfg[k] = v
}
}
return cfg
}
// presetConfigs returns config overrides keyed by preset ID.
// The "community" preset is intentionally absent — it relies entirely on
// Viper defaults.
func presetConfigs() map[string]map[string]interface{} {
return map[string]map[string]interface{}{
"solo": {
"GameplayOptions": map[string]interface{}{
"HRPMultiplier": 3.0,
"SRPMultiplier": 3.0,
"GRPMultiplier": 3.0,
"GSRPMultiplier": 3.0,
"ZennyMultiplier": 2.0,
"GZennyMultiplier": 2.0,
"MaterialMultiplier": 2.0,
"GMaterialMultiplier": 2.0,
"ExtraCarves": 2,
"GExtraCarves": 2,
},
"Entrance": map[string]interface{}{
"Entries": []map[string]interface{}{
{
"Name": "Solo",
"Type": 1,
"Channels": []map[string]interface{}{
{"Port": 54001, "MaxPlayers": 100},
},
},
},
},
},
"small": {
"Entrance": map[string]interface{}{
"Entries": []map[string]interface{}{
{
"Name": "World 1",
"Type": 1,
"Channels": []map[string]interface{}{
{"Port": 54001, "MaxPlayers": 100},
{"Port": 54002, "MaxPlayers": 100},
},
},
},
},
},
"rebalanced": {
"GameplayOptions": map[string]interface{}{
"HRPMultiplier": 2.0,
"SRPMultiplier": 2.0,
"GRPMultiplier": 2.0,
"GSRPMultiplier": 2.0,
"ExtraCarves": 1,
"GExtraCarves": 1,
},
"Entrance": map[string]interface{}{
"Entries": []map[string]interface{}{
{
"Name": "Normal",
"Type": 1,
"Channels": []map[string]interface{}{
{"Port": 54001, "MaxPlayers": 100},
{"Port": 54002, "MaxPlayers": 100},
},
},
{
"Name": "Cities",
"Type": 2,
"Channels": []map[string]interface{}{
{"Port": 54003, "MaxPlayers": 100},
{"Port": 54004, "MaxPlayers": 100},
},
},
},
},
},
}
}
// QuestStatus holds the result of a quest files check.
type QuestStatus struct {
QuestsFound bool `json:"questsFound"`
QuestCount int `json:"questCount"`
}
// checkQuestFiles checks if quest files exist in the bin/quests/ directory.
func checkQuestFiles(binPath string) QuestStatus {
if binPath == "" {
binPath = "bin"
}
questDir := filepath.Join(binPath, "quests")
entries, err := os.ReadDir(questDir)
if err != nil {
return QuestStatus{QuestsFound: false, QuestCount: 0}
}
count := 0
for _, e := range entries {
if !e.IsDir() {
count++
}
}
return QuestStatus{QuestsFound: count > 0, QuestCount: count}
}
// PresetInfo describes a gameplay preset for the wizard UI.
type PresetInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Channels int `json:"channels"`
}
// availablePresets returns the list of gameplay presets shown in the wizard.
func availablePresets() []PresetInfo {
return []PresetInfo{
{
ID: "solo",
Name: "Solo / Testing",
Description: "Single channel, boosted XP rates (3x), relaxed grind. Ideal for solo play or development testing.",
Channels: 1,
},
{
ID: "small",
Name: "Small Group (2-8 players)",
Description: "Two channels with vanilla rates. Good for friends playing together.",
Channels: 2,
},
{
ID: "community",
Name: "Community Server",
Description: "Full 8-channel topology with vanilla rates. Ready for a public community.",
Channels: 8,
},
{
ID: "rebalanced",
Name: "Rebalanced",
Description: "Community-tuned rates: 2x GRP, 2x HRP, extra carves. Addresses G-Rank grind without trivializing content.",
Channels: 4,
},
}
}
// writeConfig writes the config map to config.json with pretty formatting.
func writeConfig(config map[string]interface{}) error {
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("marshalling config: %w", err)
}
if err := os.WriteFile("config.json", data, 0600); err != nil {
return fmt.Errorf("writing config.json: %w", err)
}
return nil
}
// detectOutboundIP returns the preferred outbound IPv4 address.
func detectOutboundIP() (string, error) {
conn, err := net.Dial("udp4", "8.8.8.8:80")
if err != nil {
return "", fmt.Errorf("detecting outbound IP: %w", err)
}
defer func() { _ = conn.Close() }()
localAddr := conn.LocalAddr().(*net.UDPAddr)
return localAddr.IP.To4().String(), nil
}
// testDBConnection tests connectivity to the PostgreSQL server and checks
// whether the target database and its tables exist.
func testDBConnection(host string, port int, user, password, dbName string) (*DBStatus, error) {
status := &DBStatus{}
// Connect to the 'postgres' maintenance DB to check if target DB exists.
adminConn := fmt.Sprintf(
"host='%s' port='%d' user='%s' password='%s' dbname='postgres' sslmode=disable",
host, port, user, password,
)
adminDB, err := sql.Open("postgres", adminConn)
if err != nil {
return nil, fmt.Errorf("connecting to PostgreSQL: %w", err)
}
defer func() { _ = adminDB.Close() }()
if err := adminDB.Ping(); err != nil {
return nil, fmt.Errorf("cannot reach PostgreSQL: %w", err)
}
status.ServerReachable = true
var exists bool
err = adminDB.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", dbName).Scan(&exists)
if err != nil {
return status, fmt.Errorf("checking database existence: %w", err)
}
status.DatabaseExists = exists
if !exists {
return status, nil
}
// Connect to the target DB to check for tables.
targetConn := fmt.Sprintf(
"host='%s' port='%d' user='%s' password='%s' dbname='%s' sslmode=disable",
host, port, user, password, dbName,
)
targetDB, err := sql.Open("postgres", targetConn)
if err != nil {
return status, nil
}
defer func() { _ = targetDB.Close() }()
var tableCount int
err = targetDB.QueryRow("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'").Scan(&tableCount)
if err != nil {
return status, nil
}
status.TablesExist = tableCount > 0
status.TableCount = tableCount
return status, nil
}
// DBStatus holds the result of a database connectivity check.
type DBStatus struct {
ServerReachable bool `json:"serverReachable"`
DatabaseExists bool `json:"databaseExists"`
TablesExist bool `json:"tablesExist"`
TableCount int `json:"tableCount"`
}
// createDatabase creates the target database by connecting to the 'postgres' maintenance DB.
func createDatabase(host string, port int, user, password, dbName string) error {
adminConn := fmt.Sprintf(
"host='%s' port='%d' user='%s' password='%s' dbname='postgres' sslmode=disable",
host, port, user, password,
)
db, err := sql.Open("postgres", adminConn)
if err != nil {
return fmt.Errorf("connecting to PostgreSQL: %w", err)
}
defer func() { _ = db.Close() }()
_, err = db.Exec("CREATE DATABASE " + pq.QuoteIdentifier(dbName))
if err != nil {
return fmt.Errorf("creating database: %w", err)
}
return nil
}