feat(config): register all defaults in code, shrink example config

Only the database password is truly mandatory to get started, but
config.example.json was 267 lines with 90+ options. Newcomers faced a
wall of settings with no indication of what matters.

Add registerDefaults() with all sane defaults via Viper so a minimal
config (just DB credentials) produces a fully working server. Gameplay
multipliers default to 1.0 instead of Go's zero value 0.0, which
previously zeroed out all quest rewards for minimal configs. Uses
dot-notation defaults for GameplayOptions/DebugOptions so users can
override individual fields without losing other defaults.

- Shrink config.example.json from 267 to 10 lines
- Rename full original to config.reference.json as documentation
- Simplify wizard buildDefaultConfig() from ~220 to ~12 lines
- Fix latent bug: SaveDumps default used wrong key DevModeOptions
- Add tests: minimal config, backward compat, single-field override
- Update release workflow, README, CONTRIBUTING, docker/README
This commit is contained in:
Houmgaor
2026-02-23 21:25:07 +01:00
parent 27fb0faa1e
commit 385b974adc
11 changed files with 655 additions and 494 deletions

View File

@@ -338,22 +338,195 @@ func getOutboundIP4() (net.IP, error) {
return localAddr.IP.To4(), nil
}
// LoadConfig loads the given config toml file.
func LoadConfig() (*Config, error) {
viper.SetConfigName("config")
viper.AddConfigPath(".")
// registerDefaults sets all sane defaults via Viper so that a minimal
// config.json (just database credentials) produces a fully working server.
func registerDefaults() {
// Top-level settings
viper.SetDefault("BinPath", "bin")
viper.SetDefault("HideLoginNotice", true)
viper.SetDefault("LoginNotices", []string{
"<BODY><CENTER><SIZE_3><C_4>Welcome to Erupe!",
})
viper.SetDefault("ClientMode", "ZZ")
viper.SetDefault("QuestCacheExpiry", 300)
viper.SetDefault("CommandPrefix", "!")
viper.SetDefault("AutoCreateAccount", true)
viper.SetDefault("LoopDelay", 50)
viper.SetDefault("DefaultCourses", []uint16{1, 23, 24})
viper.SetDefault("EarthMonsters", []int32{0, 0, 0, 0})
viper.SetDefault("DevModeOptions.SaveDumps", SaveDumpOptions{
// SaveDumps
viper.SetDefault("SaveDumps", SaveDumpOptions{
Enabled: true,
OutputDir: "save-backups",
})
// Screenshots
viper.SetDefault("Screenshots", ScreenshotsOptions{
Enabled: true,
Host: "127.0.0.1",
Port: 8080,
OutputDir: "screenshots",
UploadQuality: 100,
})
// Capture
viper.SetDefault("Capture", CaptureOptions{
OutputDir: "captures",
CaptureSign: true,
CaptureEntrance: true,
CaptureChannel: true,
})
viper.SetDefault("LoopDelay", 50)
// DebugOptions (dot-notation for per-field merge)
viper.SetDefault("DebugOptions.MaxHexdumpLength", 256)
viper.SetDefault("DebugOptions.FestaOverride", -1)
viper.SetDefault("DebugOptions.AutoQuestBackport", true)
viper.SetDefault("DebugOptions.CapLink", CapLinkOptions{
Values: []uint16{51728, 20000, 51729, 1, 20000},
Port: 80,
})
// GameplayOptions (dot-notation — critical to avoid zeroing multipliers)
viper.SetDefault("GameplayOptions.MaxFeatureWeapons", 1)
viper.SetDefault("GameplayOptions.MaximumNP", 100000)
viper.SetDefault("GameplayOptions.MaximumRP", uint16(50000))
viper.SetDefault("GameplayOptions.MaximumFP", uint32(120000))
viper.SetDefault("GameplayOptions.TreasureHuntExpiry", uint32(604800))
viper.SetDefault("GameplayOptions.BoostTimeDuration", 7200)
viper.SetDefault("GameplayOptions.ClanMealDuration", 3600)
viper.SetDefault("GameplayOptions.ClanMemberLimits", [][]uint8{{0, 30}, {3, 40}, {7, 50}, {10, 60}})
viper.SetDefault("GameplayOptions.BonusQuestAllowance", uint32(3))
viper.SetDefault("GameplayOptions.DailyQuestAllowance", uint32(1))
viper.SetDefault("GameplayOptions.RegularRavienteMaxPlayers", uint8(8))
viper.SetDefault("GameplayOptions.ViolentRavienteMaxPlayers", uint8(8))
viper.SetDefault("GameplayOptions.BerserkRavienteMaxPlayers", uint8(32))
viper.SetDefault("GameplayOptions.ExtremeRavienteMaxPlayers", uint8(32))
viper.SetDefault("GameplayOptions.SmallBerserkRavienteMaxPlayers", uint8(8))
viper.SetDefault("GameplayOptions.GUrgentRate", float64(0.10))
// All reward multipliers default to 1.0 — without this, Go's zero value
// (0.0) would zero out all quest rewards for minimal configs.
for _, key := range []string{
"GCPMultiplier", "HRPMultiplier", "HRPMultiplierNC",
"SRPMultiplier", "SRPMultiplierNC", "GRPMultiplier", "GRPMultiplierNC",
"GSRPMultiplier", "GSRPMultiplierNC", "ZennyMultiplier", "ZennyMultiplierNC",
"GZennyMultiplier", "GZennyMultiplierNC", "MaterialMultiplier", "MaterialMultiplierNC",
"GMaterialMultiplier", "GMaterialMultiplierNC",
} {
viper.SetDefault("GameplayOptions."+key, float64(1.0))
}
viper.SetDefault("GameplayOptions.MezFesSoloTickets", uint32(5))
viper.SetDefault("GameplayOptions.MezFesGroupTickets", uint32(1))
viper.SetDefault("GameplayOptions.MezFesDuration", 172800)
// Discord
viper.SetDefault("Discord.RelayChannel.MaxMessageLength", 183)
// Commands (whole-struct default — replaced entirely if user provides any)
viper.SetDefault("Commands", []Command{
{Name: "Help", Enabled: true, Description: "Show enabled chat commands", Prefix: "help"},
{Name: "Rights", Enabled: false, Description: "Overwrite the Rights value on your account", Prefix: "rights"},
{Name: "Raviente", Enabled: true, Description: "Various Raviente siege commands", Prefix: "ravi"},
{Name: "Teleport", Enabled: false, Description: "Teleport to specified coordinates", Prefix: "tele"},
{Name: "Reload", Enabled: true, Description: "Reload all players in your Land", Prefix: "reload"},
{Name: "KeyQuest", Enabled: false, Description: "Overwrite your HR Key Quest progress", Prefix: "kqf"},
{Name: "Course", Enabled: true, Description: "Toggle Courses on your account", Prefix: "course"},
{Name: "PSN", Enabled: true, Description: "Link a PlayStation Network ID to your account", Prefix: "psn"},
{Name: "Discord", Enabled: true, Description: "Generate a token to link your Discord account", Prefix: "discord"},
{Name: "Ban", Enabled: false, Description: "Ban/Temp Ban a user", Prefix: "ban"},
{Name: "Timer", Enabled: true, Description: "Toggle the Quest timer", Prefix: "timer"},
{Name: "Playtime", Enabled: true, Description: "Show your playtime", Prefix: "playtime"},
})
// Courses
viper.SetDefault("Courses", []Course{
{Name: "HunterLife", Enabled: true},
{Name: "Extra", Enabled: true},
{Name: "Premium", Enabled: true},
{Name: "Assist", Enabled: false},
{Name: "N", Enabled: false},
{Name: "Hiden", Enabled: false},
{Name: "HunterSupport", Enabled: false},
{Name: "NBoost", Enabled: false},
{Name: "NetCafe", Enabled: true},
{Name: "HLRenewing", Enabled: true},
{Name: "EXRenewing", Enabled: true},
})
// Database (Password deliberately has no default)
viper.SetDefault("Database.Host", "localhost")
viper.SetDefault("Database.Port", 5432)
viper.SetDefault("Database.User", "postgres")
viper.SetDefault("Database.Database", "erupe")
// Sign server
viper.SetDefault("Sign.Enabled", true)
viper.SetDefault("Sign.Port", 53312)
// API server
viper.SetDefault("API.Enabled", true)
viper.SetDefault("API.Port", 8080)
viper.SetDefault("API.LandingPage", LandingPage{
Enabled: true,
Title: "My Frontier Server",
Content: "<p>Welcome! Server is running.</p>",
})
// Channel server
viper.SetDefault("Channel.Enabled", true)
// Entrance server
viper.SetDefault("Entrance.Enabled", true)
viper.SetDefault("Entrance.Port", uint16(53310))
boolTrue := true
viper.SetDefault("Entrance.Entries", []EntranceServerInfo{
{
Name: "Newbie", Type: 3, Recommended: 2,
Channels: []EntranceChannelInfo{
{Port: 54001, MaxPlayers: 100, Enabled: &boolTrue},
{Port: 54002, MaxPlayers: 100, Enabled: &boolTrue},
},
},
{
Name: "Normal", Type: 1,
Channels: []EntranceChannelInfo{
{Port: 54003, MaxPlayers: 100, Enabled: &boolTrue},
{Port: 54004, MaxPlayers: 100, Enabled: &boolTrue},
},
},
{
Name: "Cities", Type: 2,
Channels: []EntranceChannelInfo{
{Port: 54005, MaxPlayers: 100, Enabled: &boolTrue},
},
},
{
Name: "Tavern", Type: 4,
Channels: []EntranceChannelInfo{
{Port: 54006, MaxPlayers: 100, Enabled: &boolTrue},
},
},
{
Name: "Return", Type: 5,
Channels: []EntranceChannelInfo{
{Port: 54007, MaxPlayers: 100, Enabled: &boolTrue},
},
},
{
Name: "MezFes", Type: 6, Recommended: 6,
Channels: []EntranceChannelInfo{
{Port: 54008, MaxPlayers: 100, Enabled: &boolTrue},
},
},
})
}
// LoadConfig loads the given config toml file.
func LoadConfig() (*Config, error) {
viper.SetConfigName("config")
viper.AddConfigPath(".")
registerDefaults()
err := viper.ReadInConfig()
if err != nil {

View File

@@ -2,8 +2,11 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/viper"
)
// TestLoadConfigNoFile tests LoadConfig when config file doesn't exist
@@ -497,3 +500,191 @@ func BenchmarkConfigCreation(b *testing.B) {
}
}
}
// writeMinimalConfig writes a minimal config.json to dir and returns its path.
func writeMinimalConfig(t *testing.T, dir, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(content), 0644); err != nil {
t.Fatalf("writing config.json: %v", err)
}
}
// TestMinimalConfigDefaults verifies that a minimal config.json produces a fully
// populated Config with sane defaults (multipliers not zero, entrance entries present, etc).
func TestMinimalConfigDefaults(t *testing.T) {
viper.Reset()
dir := t.TempDir()
origDir, _ := os.Getwd()
defer func() { _ = os.Chdir(origDir) }()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
writeMinimalConfig(t, dir, `{
"Database": { "Password": "test" }
}`)
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
// Multipliers must be 1.0 (not Go's zero value 0.0)
multipliers := map[string]float32{
"HRPMultiplier": cfg.GameplayOptions.HRPMultiplier,
"SRPMultiplier": cfg.GameplayOptions.SRPMultiplier,
"GRPMultiplier": cfg.GameplayOptions.GRPMultiplier,
"ZennyMultiplier": cfg.GameplayOptions.ZennyMultiplier,
"MaterialMultiplier": cfg.GameplayOptions.MaterialMultiplier,
"GCPMultiplier": cfg.GameplayOptions.GCPMultiplier,
"GMaterialMultiplier": cfg.GameplayOptions.GMaterialMultiplier,
}
for name, val := range multipliers {
if val != 1.0 {
t.Errorf("%s = %v, want 1.0", name, val)
}
}
// Entrance entries should be present
if len(cfg.Entrance.Entries) != 6 {
t.Errorf("Entrance.Entries = %d, want 6", len(cfg.Entrance.Entries))
}
// Commands should be present
if len(cfg.Commands) != 12 {
t.Errorf("Commands = %d, want 12", len(cfg.Commands))
}
// Courses should be present
if len(cfg.Courses) != 11 {
t.Errorf("Courses = %d, want 11", len(cfg.Courses))
}
// Standard ports
if cfg.Sign.Port != 53312 {
t.Errorf("Sign.Port = %d, want 53312", cfg.Sign.Port)
}
if cfg.API.Port != 8080 {
t.Errorf("API.Port = %d, want 8080", cfg.API.Port)
}
if cfg.Entrance.Port != 53310 {
t.Errorf("Entrance.Port = %d, want 53310", cfg.Entrance.Port)
}
// Servers enabled by default
if !cfg.Sign.Enabled {
t.Error("Sign.Enabled should be true")
}
if !cfg.API.Enabled {
t.Error("API.Enabled should be true")
}
if !cfg.Channel.Enabled {
t.Error("Channel.Enabled should be true")
}
if !cfg.Entrance.Enabled {
t.Error("Entrance.Enabled should be true")
}
// Database defaults
if cfg.Database.Host != "localhost" {
t.Errorf("Database.Host = %q, want localhost", cfg.Database.Host)
}
if cfg.Database.Port != 5432 {
t.Errorf("Database.Port = %d, want 5432", cfg.Database.Port)
}
// ClientMode defaults to ZZ
if cfg.RealClientMode != ZZ {
t.Errorf("RealClientMode = %v, want ZZ", cfg.RealClientMode)
}
// BinPath default
if cfg.BinPath != "bin" {
t.Errorf("BinPath = %q, want bin", cfg.BinPath)
}
// Gameplay limits
if cfg.GameplayOptions.MaximumNP != 100000 {
t.Errorf("MaximumNP = %d, want 100000", cfg.GameplayOptions.MaximumNP)
}
}
// TestFullConfigBackwardCompat verifies that existing full configs still load correctly.
func TestFullConfigBackwardCompat(t *testing.T) {
viper.Reset()
dir := t.TempDir()
origDir, _ := os.Getwd()
defer func() { _ = os.Chdir(origDir) }()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
// Read the reference config (the full original config.example.json).
// Look in the project root (one level up from config/).
refPath := filepath.Join(origDir, "..", "config.reference.json")
refData, err := os.ReadFile(refPath)
if err != nil {
t.Skipf("config.reference.json not found at %s, skipping backward compat test", refPath)
}
writeMinimalConfig(t, dir, string(refData))
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig() with full config error: %v", err)
}
// Spot-check values from the reference config
if cfg.GameplayOptions.HRPMultiplier != 1.0 {
t.Errorf("HRPMultiplier = %v, want 1.0", cfg.GameplayOptions.HRPMultiplier)
}
if cfg.Sign.Port != 53312 {
t.Errorf("Sign.Port = %d, want 53312", cfg.Sign.Port)
}
if len(cfg.Entrance.Entries) != 6 {
t.Errorf("Entrance.Entries = %d, want 6", len(cfg.Entrance.Entries))
}
if len(cfg.Commands) != 12 {
t.Errorf("Commands = %d, want 12", len(cfg.Commands))
}
if cfg.GameplayOptions.MaximumNP != 100000 {
t.Errorf("MaximumNP = %d, want 100000", cfg.GameplayOptions.MaximumNP)
}
}
// TestSingleFieldOverride verifies that overriding one field in a dot-notation
// section doesn't clobber other fields' defaults.
func TestSingleFieldOverride(t *testing.T) {
viper.Reset()
dir := t.TempDir()
origDir, _ := os.Getwd()
defer func() { _ = os.Chdir(origDir) }()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
writeMinimalConfig(t, dir, `{
"Database": { "Password": "test" },
"GameplayOptions": { "HRPMultiplier": 2.0 }
}`)
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
// Overridden field
if cfg.GameplayOptions.HRPMultiplier != 2.0 {
t.Errorf("HRPMultiplier = %v, want 2.0", cfg.GameplayOptions.HRPMultiplier)
}
// Other multipliers should retain defaults
if cfg.GameplayOptions.SRPMultiplier != 1.0 {
t.Errorf("SRPMultiplier = %v, want 1.0 (should retain default)", cfg.GameplayOptions.SRPMultiplier)
}
if cfg.GameplayOptions.ZennyMultiplier != 1.0 {
t.Errorf("ZennyMultiplier = %v, want 1.0 (should retain default)", cfg.GameplayOptions.ZennyMultiplier)
}
if cfg.GameplayOptions.GCPMultiplier != 1.0 {
t.Errorf("GCPMultiplier = %v, want 1.0 (should retain default)", cfg.GameplayOptions.GCPMultiplier)
}
}