// Package versionconfig loads repository-wide game compatibility, component // release, seed, and resource versions. The same versions.json drives plugins. package versionconfig import ( "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "regexp" "strings" "sync" ) const FileName = "versions.json" var ( semanticVersion = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) releaseVersion = regexp.MustCompile(`^([0-9]+\.[0-9]+\.[0-9]+)\+(client|server)\.([0-9]+\.[0-9]+\.[0-9]+)$`) resourceVersion = regexp.MustCompile(`^[0-9]{14}$`) currentMu sync.RWMutex current *Config ) // Config is the single version selection shared by the server and plugins. // SourcePath is populated by Load and is not part of the JSON document. type Config struct { GameVersion string `json:"game_version"` ClientVersion string `json:"client_version"` ServerVersion string `json:"server_version"` GameDataVersion string `json:"game_data_version"` BundleVersion string `json:"bundle_version"` SeedDirectory string `json:"seed_directory"` Plugins PluginVersions `json:"plugins"` SourcePath string `json:"-"` } type PluginVersions struct { LocalIdentity string `json:"local_identity"` CaptureEnvironment string `json:"capture_environment"` LoginUI string `json:"login_ui"` CashShop string `json:"cash_shop"` } // Load reads one explicit version file. Unknown fields and trailing JSON are // rejected so a misspelled version key cannot silently select stale data. func Load(path string) (Config, error) { path, err := filepath.Abs(filepath.Clean(path)) if err != nil { return Config{}, fmt.Errorf("versionconfig: resolve path: %w", err) } data, err := os.ReadFile(path) if err != nil { return Config{}, fmt.Errorf("versionconfig: read %s: %w", path, err) } var cfg Config decoder := json.NewDecoder(strings.NewReader(string(data))) decoder.DisallowUnknownFields() if err := decoder.Decode(&cfg); err != nil { return Config{}, fmt.Errorf("versionconfig: decode %s: %w", path, err) } var trailing any if err := decoder.Decode(&trailing); err == nil { return Config{}, fmt.Errorf("versionconfig: trailing JSON in %s", path) } else if !errors.Is(err, io.EOF) { return Config{}, fmt.Errorf("versionconfig: trailing data in %s: %w", path, err) } cfg.SourcePath = path if err := cfg.Validate(); err != nil { return Config{}, fmt.Errorf("versionconfig: %s: %w", path, err) } return cfg, nil } func Game() string { return Current().GameVersion } func Server() string { return Current().ServerVersion } func State() string { return Current().GameVersion } func GameData() string { return Current().GameDataVersion } func Bundle() string { return Current().BundleVersion } func (c Config) Validate() error { for name, value := range map[string]string{ "game_version": c.GameVersion, "plugins.local_identity": c.Plugins.LocalIdentity, "plugins.capture_environment": c.Plugins.CaptureEnvironment, "plugins.login_ui": c.Plugins.LoginUI, } { if !semanticVersion.MatchString(value) { return fmt.Errorf("%s must be a numeric three-part version", name) } } if err := validateReleaseVersion("client_version", c.ClientVersion, c.GameVersion, "client"); err != nil { return err } if err := validateReleaseVersion("server_version", c.ServerVersion, c.GameVersion, "server"); err != nil { return err } for name, value := range map[string]string{ "game_data_version": c.GameDataVersion, "bundle_version": c.BundleVersion, } { if !resourceVersion.MatchString(value) { return fmt.Errorf("%s must be a 14-digit version", name) } } if c.SeedDirectory == "" || filepath.IsAbs(c.SeedDirectory) { return errors.New("seed_directory must be a non-empty relative path") } clean := filepath.Clean(filepath.FromSlash(c.SeedDirectory)) if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return errors.New("seed_directory must stay below the version file") } return nil } func validateReleaseVersion(name, value, game, component string) error { match := releaseVersion.FindStringSubmatch(value) if len(match) != 4 || match[1] != game || match[2] != component { return fmt.Errorf("%s must be %s+%s.X.Y.Z", name, game, component) } return nil } // Resolve interprets a configured repository asset relative to versions.json, // rather than relative to the process working directory. func (c Config) Resolve(relative string) string { return filepath.Join(filepath.Dir(c.SourcePath), filepath.FromSlash(relative)) } // Find searches an explicit environment override first, then walks upward from // the executable and working directory. Release packages put versions.json // beside the executable; development commands find the repository root. func Find() (Config, error) { if path := os.Getenv("BD2_VERSION_CONFIG"); path != "" { return Load(path) } var starts []string if executable, err := os.Executable(); err == nil { starts = append(starts, filepath.Dir(executable)) } if working, err := os.Getwd(); err == nil { starts = append(starts, working) } seen := map[string]bool{} for _, start := range starts { for directory := filepath.Clean(start); ; directory = filepath.Dir(directory) { candidate := filepath.Join(directory, FileName) key := strings.ToLower(candidate) if !seen[key] { seen[key] = true if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return Load(candidate) } } parent := filepath.Dir(directory) if parent == directory { break } } } return Config{}, fmt.Errorf("versionconfig: %s not found; pass --version-config or set BD2_VERSION_CONFIG", FileName) } // Use selects an explicitly loaded configuration for all server packages. func Use(cfg Config) { currentMu.Lock() defer currentMu.Unlock() copy := cfg current = © } // Current returns the process-wide selection, locating versions.json lazily in // tests and development tools. Configuration failures are fatal programming or // packaging errors and therefore panic before any state mutation can occur. func Current() Config { currentMu.RLock() if current != nil { cfg := *current currentMu.RUnlock() return cfg } currentMu.RUnlock() cfg, err := Find() if err != nil { panic(err) } Use(cfg) return cfg }