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

@@ -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)
}
}