diff --git a/Makefile b/Makefile index b7939b27..4dfdf8c6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ + .DEFAULT_GOAL := build VERSION ?= $(shell git rev-parse HEAD) @@ -120,7 +121,7 @@ coverage: rm -rf bin .PHONY: js-lint -js-lint: +js-lint: ### Run Javascript linter # Grep filtering it to remove errors reported by docker image around npm packages # if "### errors" is found in the output, exits with an error code of 1 # This should allow us to use it in CI/CD diff --git a/_datafiles/config.yaml b/_datafiles/config.yaml index c6780fc3..d42ea917 100755 --- a/_datafiles/config.yaml +++ b/_datafiles/config.yaml @@ -78,6 +78,7 @@ Server: # accidental changes that could break the game. Locked: - FilePaths + - Server.CurrentVersion - Server.NextRoomId - Server.Seed - Server.OnLoginCommands diff --git a/_datafiles/world/default/mobs/tutorial/scripts/58-training_dummy.js b/_datafiles/world/default/mobs/tutorial/scripts/58-training_dummy.js index cbf4b9c6..536c3624 100644 --- a/_datafiles/world/default/mobs/tutorial/scripts/58-training_dummy.js +++ b/_datafiles/world/default/mobs/tutorial/scripts/58-training_dummy.js @@ -4,8 +4,9 @@ function onDie(mob, room, eventDetails) { room.SendText( mob.GetCharacterName(true) + " crumbles to dust." ); - room.GetMob(teacherMobId, true); - - teacherMob.Command('say You did it! As you can see you gain experience points for combat victories.'); - teacherMob.Command('say Now head west to complete your training.', 2.0); + var teacherMob = room.GetMob(teacherMobId, true); + if ( teacherMob != null ) { + teacherMob.Command('say You did it! As you can see you gain experience points for combat victories.'); + teacherMob.Command('say Now head west to complete your training.', 2.0); + } } diff --git a/_datafiles/world/empty/mobs/tutorial/scripts/58-training_dummy.js b/_datafiles/world/empty/mobs/tutorial/scripts/58-training_dummy.js index 5b3e2c9b..536c3624 100644 --- a/_datafiles/world/empty/mobs/tutorial/scripts/58-training_dummy.js +++ b/_datafiles/world/empty/mobs/tutorial/scripts/58-training_dummy.js @@ -4,7 +4,9 @@ function onDie(mob, room, eventDetails) { room.SendText( mob.GetCharacterName(true) + " crumbles to dust." ); - teacherMob = room.GetMob(teacherMobId, true); - - teacherMob.Command('say You did it! Head west to complete your training.'); + var teacherMob = room.GetMob(teacherMobId, true); + if ( teacherMob != null ) { + teacherMob.Command('say You did it! As you can see you gain experience points for combat victories.'); + teacherMob.Command('say Now head west to complete your training.', 2.0); + } } diff --git a/internal/configs/config.server.go b/internal/configs/config.server.go index 5314e47f..723dc1ef 100644 --- a/internal/configs/config.server.go +++ b/internal/configs/config.server.go @@ -2,6 +2,7 @@ package configs type Server struct { MudName ConfigString `yaml:"MudName"` // Name of the MUD + CurrentVersion ConfigString `yaml:"CurrentVersion"` // Current version this mud has been updated to Seed ConfigSecret `yaml:"Seed"` // Seed that may be used for generating content MaxCPUCores ConfigInt `yaml:"MaxCPUCores"` // How many cores to allow for multi-core operations OnLoginCommands ConfigSliceString `yaml:"OnLoginCommands"` // Commands to run when a user logs in @@ -26,6 +27,10 @@ func (s *Server) Validate() { s.MaxCPUCores = 0 // default } + if s.CurrentVersion == `` { + s.CurrentVersion = `0.9.0` // If no version found, failover to a known version + } + } func GetServerConfig() Server { diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 357cce94..ca191ade 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -11,13 +11,21 @@ import ( "github.com/GoMudEngine/GoMud/internal/mudlog" ) -func HandleFlags() { +func HandleFlags(serverVersion string) { + var portsearch string + var showVersion bool flag.StringVar(&portsearch, "port-search", "", "Search for the first 10 open ports: -port-search=30000-40000") + flag.BoolVar(&showVersion, "version", false, "Display the current binary version") flag.Parse() + if showVersion { + fmt.Println(serverVersion) + os.Exit(0) + } + if portsearch != `` { doPortSearch(portsearch) os.Exit(0) diff --git a/internal/migration/0.9.1.go b/internal/migration/0.9.1.go new file mode 100644 index 00000000..0356d076 --- /dev/null +++ b/internal/migration/0.9.1.go @@ -0,0 +1,231 @@ +package migration + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/GoMudEngine/GoMud/internal/configs" + "github.com/GoMudEngine/GoMud/internal/mudlog" + "github.com/GoMudEngine/GoMud/internal/rooms" + "gopkg.in/yaml.v2" +) + +// Description: +// rooms.Room.ZoneConfig was removed when Zone data was migrated to zone-config.yaml in zone folders +// This function loads all of the yaml files in the DATAFILES/world/*/rooms/* and looks for any ZoneConfig data. +// If found, the data is moved to a zone-config.yaml file, and the ZoneConfig data in the Room datafile is removed. +func migrate_RoomZoneConfig() error { + + // This struct is how ZoneConfig looked as of 0.9.1 + // Since we will be upgrading an older version to this format, use a copy of the struct from that period + // To ensure we aren't using a struct that has changed over time + type zoneConfig_1_0_0 struct { + Name string `yaml:"name,omitempty"` + RoomId int `yaml:"roomid,omitempty"` + MobAutoScale struct { + Minimum int `yaml:"minimum,omitempty"` // level scaling minimum + Maximum int `yaml:"maximum,omitempty"` // level scaling maximum + } `yaml:"autoscale,omitempty"` // level scaling range if any + Mutators []struct { + MutatorId string `yaml:"mutatorid,omitempty"` // Short text that will uniquely identify this modifier ("dusty") + SpawnedRound uint64 `yaml:"spawnedround,omitempty"` // Tracks when this mutator was created (useful for decay) + DespawnedRound uint64 `yaml:"despawnedround,omitempty"` // Track when it decayed to nothing. + } `yaml:"mutators,omitempty"` + IdleMessages []string `yaml:"idlemessages,omitempty"` // list of messages that can be displayed to players in the zone, assuming a room has none defined + MusicFile string `yaml:"musicfile,omitempty"` // background music to play when in this zone + DefaultBiome string `yaml:"defaultbiome,omitempty"` // city, swamp etc. see biomes.go + RoomIds map[int]struct{} `yaml:"-"` // Does not get written. Built dyanmically when rooms are loaded. + } + + c := configs.GetConfig() + + worldfilesGlob := filepath.Join(string(c.FilePaths.DataFiles), "rooms", "*", "*.yaml") + matches, err := filepath.Glob(worldfilesGlob) + + if err != nil { + return err + } + + existingZoneFiles := map[string]struct{}{} + + // We only care about room files, so ###.yaml (possible negative) + re := regexp.MustCompile(`^[\-0-9]+\.yaml$`) + for _, path := range matches { + + // + // Must look like a room yaml file: + // 1.yaml + // 123.yaml + // -83.yaml + // etc. + // + + if !re.MatchString(filepath.Base(path)) { + continue + } + + // + // strip the filename form the room file and replace with zone-config.yaml + // to get the path to the zone-config.yaml + // + zoneFilePath := filepath.Join(filepath.Dir(path), "zone-config.yaml") + + // + // The following checks whether the zone config file already exists + // We will leave the config data in the room data file if the zone-config.yaml is already present. + // It should be inert if present, since it is not unmarshalled into anything in current code. + // + + // Check whether zone file already is tracked as existing, if found, skip. + if _, ok := existingZoneFiles[zoneFilePath]; ok { + continue + } + + _, err = os.Stat(zoneFilePath) + if err == nil { + // Mark zone file as existing, skip further processing. + existingZoneFiles[zoneFilePath] = struct{}{} + continue + } + + // + // End check for existing zone-config.yaml + // After this point, we will unmarshal the yaml file into a generic map structure. + // This allows us to examine the data in the yaml file, particularly the "zoneconfig" node + // since the ZoneConfig field has been removed from the rooms.Room struct + // We can de-populate the field, move it, and re-write the yaml back to the original room template file. + // The downside to this method is that being a map, the fields will be read/written in a non-deterministic manner, + // So the room yaml file field orders may be written in a random order. + // Because of this, and as a final fix, we will finally marshal/unmarshal into the proper room struct from the map data + // Allowing us to write the data in an expected ordered form. + // + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + // + // First do a simple check for the field name in the text file. + // We know the way the field will appear: "zoneconfig:" + // This avoids having to unmarshal the struct and search that way, unnecessarily. + // + if !strings.Contains(string(data), "zoneconfig:") { + continue + } + + // + // Unmarshal the entire yaml file into a map + // This will let us further examine the data, modify it, etc. + // + filedata := map[string]any{} + err = yaml.Unmarshal(data, &filedata) + if err != nil { + return fmt.Errorf("failed to parse YAML: %w", err) + } + + // Make sure that the zoneconfig key is present and populated + if filedata[`zoneconfig`] == nil { + continue + } + + mudlog.Info("Migration 0.9.1", "file", path, "message", "migrating zoneconfig from room data file to zone-config.yaml") + + // + // From here on out, this code migrates zoneconfig data out of room file and into zone-config.yaml + // + roomFileInfo, _ := os.Stat(path) + + mudlog.Info("Migration 0.9.1", "file", path, "message", "isolating zoneconfig data") + + // + // Isolate the zoneconfig and write it to its own zone-config.yaml file + // We'll marshal just the zoneconfig data, get its bytes, then unmarshal it into + // the desired target structure. + // Some fields have changed or are missing due to some slight differences in the new struct + // so we'll also try and reconcile some of that by pulling from the core room definition + // + zoneBytes, err := yaml.Marshal(filedata[`zoneconfig`]) + if err != nil { + return err + } + + zoneDataStruct := zoneConfig_1_0_0{} + + if err = yaml.Unmarshal(zoneBytes, &zoneDataStruct); err != nil { + return err + } + + if filedata[`zone`] != nil { + if zoneName, ok := filedata[`zone`].(string); ok { + zoneDataStruct.Name = zoneName + } else { + zoneDataStruct.Name = filedata[`title`].(string) + } + + if defaultBiome, ok := filedata[`biome`].(string); ok { + zoneDataStruct.DefaultBiome = defaultBiome + } + } + + mudlog.Info("Migration 0.9.1", "file", path, "message", "writing "+zoneFilePath) + + // + // Write the zone data to the zone-config.yaml path + // We'll just use whatever permissions were set in the room file for this file. + // + zoneFileBytes, err := yaml.Marshal(zoneDataStruct) + if err != nil { + return err + } + if err := os.WriteFile(zoneFilePath, zoneFileBytes, roomFileInfo.Mode().Perm()); err != nil { + return err + } + + // Mark zone file as existing + existingZoneFiles[zoneFilePath] = struct{}{} + + mudlog.Info("Migration 0.9.1", "file", path, "message", "writing modified room data") + + // + // Now clear the "zoneconfig" node from the room data. + // The data will be in a random order if we just write this back to the room yaml file, + // so we'll take the extract step of marshalling the room data from the map into a string, + // and then unmarshal it into the actual target rooms.Room{} struct. + // This way, when writing to a file, it'll be in the typical field order according to the struct + // field order. + // + delete(filedata, `zoneconfig`) + + // First marshal the modified room data into bytes + modifiedRoomBytes, err := yaml.Marshal(filedata) + if err != nil { + return err + } + + // Unmarshal the bytes into the proper struct + modifiedRoomStruct := rooms.Room{} + if err = yaml.Unmarshal(modifiedRoomBytes, &modifiedRoomStruct); err != nil { + return err + } + + // Marshal again, this time using the proper struct + modifiedRoomBytes, err = yaml.Marshal(modifiedRoomStruct) + if err != nil { + return err + } + + // Again, we'll just use the rooms original permissions when writing. + if err := os.WriteFile(path, modifiedRoomBytes, roomFileInfo.Mode().Perm()); err != nil { + return err + } + + mudlog.Info("Migration 0.9.1", "file", path, "message", "successfully updated") + + } + + return nil +} diff --git a/internal/migration/backup.go b/internal/migration/backup.go new file mode 100644 index 00000000..1d8ea063 --- /dev/null +++ b/internal/migration/backup.go @@ -0,0 +1,73 @@ +package migration + +import ( + "errors" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/GoMudEngine/GoMud/internal/configs" +) + +func datafilesBackup() (string, error) { + + tmpDir, err := os.MkdirTemp("", "datafiles_backup_*") + if err != nil { + return "", err + } + + c := configs.GetConfig() + datafilesFolder := string(c.FilePaths.DataFiles) + + err = copyDir(datafilesFolder, tmpDir) + if err != nil { + return "", err + } + + return tmpDir, nil +} + +func copyDir(src string, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + + destPath := filepath.Join(dst, relPath) + + if d.IsDir() { + _, err := os.Stat(destPath) + if errors.Is(err, os.ErrNotExist) { + return os.MkdirAll(destPath, 0755) + } + return nil + } + + // It’s a file + return copyFile(path, destPath) + }) +} + +// CopyFile copies a single file +func copyFile(srcFile, dstFile string) error { + srcF, err := os.Open(srcFile) + if err != nil { + return err + } + defer srcF.Close() + + dstF, err := os.Create(dstFile) + if err != nil { + return err + } + defer dstF.Close() + + _, err = io.Copy(dstF, srcF) + return err +} diff --git a/internal/migration/migration.go b/internal/migration/migration.go new file mode 100644 index 00000000..188f01c5 --- /dev/null +++ b/internal/migration/migration.go @@ -0,0 +1,62 @@ +package migration + +import ( + "fmt" + "os" + + "github.com/GoMudEngine/GoMud/internal/configs" + "github.com/GoMudEngine/GoMud/internal/version" +) + +// Migration code goes here. +// They should be put in the order of oldest to newest and follow the pattern as below +func doAllMigrations(lastConfigVersion version.Version) error { + + // 0.0.0 -> 0.9.1 + if lastConfigVersion.IsOlderThan(version.New(0, 9, 1)) { + + if err := migrate_RoomZoneConfig(); err != nil { + return err + } + + } + + return nil +} + +// Entrypoint for migrations. +// This is run on server start-up, after config files are loaded. +// NOTE: This means migrations that modify config files themselves would need special consideration +func Run(lastConfigVersion version.Version, serverVersion version.Version) error { + + // + // If already up to speed on version, we don't really need to do anything. + // + if lastConfigVersion.IsEqualTo(serverVersion) { + return nil + } + + // + // Start by making a backup of all datafiles. + // + backupFolder, err := datafilesBackup() + if err != nil { + return fmt.Errorf(`could not backup datafiles: %w`, err) + } + defer os.RemoveAll(backupFolder) + + // + // If an error occured, restore backup + // + if err := doAllMigrations(lastConfigVersion); err != nil { + copyDir(backupFolder, string(configs.GetFilePathsConfig().DataFiles)) + return err + } + + // + // Finally, since successful, update to the version this migration is for + // + configs.SetVal(`Server.CurrentVersion`, serverVersion.String()) + + return nil +} diff --git a/internal/rooms/rooms.go b/internal/rooms/rooms.go index 32363e5d..4cd74bfe 100644 --- a/internal/rooms/rooms.go +++ b/internal/rooms/rooms.go @@ -30,7 +30,6 @@ var ( "*": defaultMapSymbol, //"•": "*", } - ) type FindFlag uint16 @@ -65,34 +64,33 @@ const ( type Room struct { //mutex - RoomId int `yaml:"roomid"` // a unique numeric index of the room. Also the filename. - Zone string `yaml:"zone"` // zone is a way to partition rooms into groups. Also into folders. - ZoneConfig *ZoneConfig `yaml:"zoneconfig,omitempty" instance:"skip"` // If non-null is a root room. - MusicFile string `yaml:"musicfile,omitempty"` // background music to play when in this room - IsBank bool `yaml:"isbank,omitempty"` // Is this a bank room? If so, players can deposit/withdraw gold here. - IsStorage bool `yaml:"isstorage,omitempty"` // Is this a storage room? If so, players can add/remove objects here. - IsCharacterRoom bool `yaml:"ischaracterroom,omitempty"` // Is this a room where characters can create new characters to swap between them? - Title string `yaml:"title"` // Title shown to the user - Description string `yaml:"description"` // Description shown to the user - MapSymbol string `yaml:"mapsymbol,omitempty"` // The symbol to use when generating a map of the zone - MapLegend string `yaml:"maplegend,omitempty"` // The text to display in the legend for this room. Should be one word. - Biome string `yaml:"biome,omitempty"` // The biome of the room. Used for weather generation. - Containers map[string]Container `yaml:"containers,omitempty"` // If this room has a chest, what is in it? - Exits map[string]exit.RoomExit `yaml:"exits"` // Exits to other rooms - ExitsTemp map[string]exit.TemporaryRoomExit `yaml:"-"` // Temporary exits that will be removed after a certain time. Don't bother saving on sever shutting down. - Nouns map[string]string `yaml:"nouns,omitempty"` // Interesting nouns to highlight in the room or reveal on succesful searches. - Items []items.Item `yaml:"items,omitempty"` // Items on the floor - Stash []items.Item `yaml:"stash,omitempty"` // list of items in the room that are not visible to players - Corpses []Corpse `yaml:"-"` // Any corpses laying around from recent deaths - Gold int `yaml:"gold,omitempty"` // How much gold is on the ground? - SpawnInfo []SpawnInfo `yaml:"spawninfo,omitempty" instance:"skip"` // key is creature ID, value is spawn chance - SkillTraining map[string]TrainingRange `yaml:"skilltraining,omitempty"` // list of skills that can be trained in this room - Signs []Sign `yaml:"sign,omitempty"` // list of scribbles in the room - IdleMessages []string `yaml:"idlemessages,omitempty" ` // list of messages that can be displayed to players in the room - LastIdleMessage uint8 `yaml:"-"` // index of the last idle message displayed - LongTermDataStore map[string]any `yaml:"longtermdatastore,omitempty"` // Long term data store for the room - Mutators mutators.MutatorList `yaml:"mutators,omitempty"` // mutators this room spawns with. - Pvp bool `yaml:"pvp,omitempty"` // if config pvp is set to `limited`, uses this value + RoomId int `yaml:"roomid"` // a unique numeric index of the room. Also the filename. + Zone string `yaml:"zone"` // zone is a way to partition rooms into groups. Also into folders. + MusicFile string `yaml:"musicfile,omitempty"` // background music to play when in this room + IsBank bool `yaml:"isbank,omitempty"` // Is this a bank room? If so, players can deposit/withdraw gold here. + IsStorage bool `yaml:"isstorage,omitempty"` // Is this a storage room? If so, players can add/remove objects here. + IsCharacterRoom bool `yaml:"ischaracterroom,omitempty"` // Is this a room where characters can create new characters to swap between them? + Title string `yaml:"title"` // Title shown to the user + Description string `yaml:"description"` // Description shown to the user + MapSymbol string `yaml:"mapsymbol,omitempty"` // The symbol to use when generating a map of the zone + MapLegend string `yaml:"maplegend,omitempty"` // The text to display in the legend for this room. Should be one word. + Biome string `yaml:"biome,omitempty"` // The biome of the room. Used for weather generation. + Containers map[string]Container `yaml:"containers,omitempty"` // If this room has a chest, what is in it? + Exits map[string]exit.RoomExit `yaml:"exits"` // Exits to other rooms + ExitsTemp map[string]exit.TemporaryRoomExit `yaml:"-"` // Temporary exits that will be removed after a certain time. Don't bother saving on sever shutting down. + Nouns map[string]string `yaml:"nouns,omitempty"` // Interesting nouns to highlight in the room or reveal on succesful searches. + Items []items.Item `yaml:"items,omitempty"` // Items on the floor + Stash []items.Item `yaml:"stash,omitempty"` // list of items in the room that are not visible to players + Corpses []Corpse `yaml:"-"` // Any corpses laying around from recent deaths + Gold int `yaml:"gold,omitempty"` // How much gold is on the ground? + SpawnInfo []SpawnInfo `yaml:"spawninfo,omitempty" instance:"skip"` // key is creature ID, value is spawn chance + SkillTraining map[string]TrainingRange `yaml:"skilltraining,omitempty"` // list of skills that can be trained in this room + Signs []Sign `yaml:"sign,omitempty"` // list of scribbles in the room + IdleMessages []string `yaml:"idlemessages,omitempty" ` // list of messages that can be displayed to players in the room + LastIdleMessage uint8 `yaml:"-"` // index of the last idle message displayed + LongTermDataStore map[string]any `yaml:"longtermdatastore,omitempty"` // Long term data store for the room + Mutators mutators.MutatorList `yaml:"mutators,omitempty"` // mutators this room spawns with. + Pvp bool `yaml:"pvp,omitempty"` // if config pvp is set to `limited`, uses this value // Unexported/private players []int // list of user IDs currently in the room mobs []int // list of mob instance IDs currently in the room. Does not get saved. diff --git a/internal/rooms/save_and_load.go b/internal/rooms/save_and_load.go index 180c3b3d..5bdd348f 100644 --- a/internal/rooms/save_and_load.go +++ b/internal/rooms/save_and_load.go @@ -400,28 +400,6 @@ func loadAllRoomZones() error { for _, loadedRoom := range loadedRooms { - // - // This code migrates old format data to the new format (separate zone file) - // - if loadedRoom.ZoneConfig != nil { - if loadedRoom.ZoneConfig.RoomId == loadedRoom.RoomId { - if _, ok := roomManager.zones[loadedRoom.Zone]; !ok { - newZone := NewZoneConfig(loadedRoom.Zone) - newZone.DefaultBiome = loadedRoom.Biome - newZone.IdleMessages = loadedRoom.ZoneConfig.IdleMessages - newZone.MusicFile = loadedRoom.ZoneConfig.MusicFile - newZone.MobAutoScale = loadedRoom.ZoneConfig.MobAutoScale - newZone.Mutators = loadedRoom.ZoneConfig.Mutators - newZone.RoomId = loadedRoom.ZoneConfig.RoomId - if err := SaveZoneConfig(newZone); err != nil { - return err - } - loadedRoom.ZoneConfig = nil // if successfully saved, blank out the ZoneConfig for the room - SaveRoomTemplate(*loadedRoom) - } - } - } - // configs.GetConfig().DeathRecoveryRoom is the death/shadow realm and gets a pass if loadedRoom.RoomId == int(configs.GetSpecialRoomsConfig().DeathRecoveryRoom) { continue diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 00000000..a687a7ff --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,98 @@ +package version + +import ( + "fmt" + "strconv" + "strings" +) + +const ( + Older = -1 + Newer = 1 + Equal = 0 +) + +type Version struct { + Major int + Minor int + Patch int +} + +func (v Version) String() string { + return fmt.Sprintf(`%d.%d.%d`, v.Major, v.Minor, v.Patch) +} + +func (v Version) Compare(other Version) int { + if v.Major != other.Major { + if v.Major < other.Major { + return Older + } + return Newer + } + if v.Minor != other.Minor { + if v.Minor < other.Minor { + return Older + } + return Newer + } + if v.Patch != other.Patch { + if v.Patch < other.Patch { + return Older + } + return Newer + } + return Equal +} + +func (v Version) IsNewerThan(other Version) bool { + return v.Compare(other) == Newer +} + +func (v Version) IsOlderThan(other Version) bool { + return v.Compare(other) == Older +} + +func (v Version) IsEqualTo(other Version) bool { + return v.Compare(other) == Equal +} + +func New(major int, minor int, patch int) Version { + return Version{major, minor, patch} +} + +func Parse(v string) (Version, error) { + // lowercase it all for predicatability + s := strings.ToLower(v) + + // Remove leading "v" if present + s = strings.TrimPrefix(s, "v") + + parts := strings.Split(s, ".") + if len(parts) < 2 || len(parts) > 3 { + return Version{}, fmt.Errorf("invalid version format: %s", s) + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return Version{}, fmt.Errorf("invalid major version: %v", err) + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return Version{}, fmt.Errorf("invalid minor version: %v", err) + } + + patch := 0 + if len(parts) == 3 { + patch, err = strconv.Atoi(parts[2]) + if err != nil { + return Version{}, fmt.Errorf("invalid patch version: %v", err) + } + } + + if major == 0 && minor == 0 && patch == 0 { + return Version{}, fmt.Errorf("invalid version: %s", v) + } + + return Version{Major: major, Minor: minor, Patch: patch}, nil +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 00000000..3077a3f9 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,81 @@ +package version + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParse(t *testing.T) { + tests := []struct { + input string + expected Version + hasError bool + }{ + {"0.9.0", Version{0, 9, 0}, false}, + {"v0.9.0", Version{0, 9, 0}, false}, + {"1.2.3", Version{1, 2, 3}, false}, + {"v1.2.3", Version{1, 2, 3}, false}, + {"2.0", Version{2, 0, 0}, false}, + {"v2.0", Version{2, 0, 0}, false}, + {"10.20.30", Version{10, 20, 30}, false}, + {"V10.20.30", Version{10, 20, 30}, false}, + + // Invalid cases + {"", Version{}, true}, + {"v", Version{}, true}, + {"1", Version{}, true}, + {"v1", Version{}, true}, + {"0.0.0", Version{0, 0, 0}, true}, + {"v0.0.0", Version{0, 0, 0}, true}, + {"1.2.3.4", Version{}, true}, + {"v1.2.beta", Version{}, true}, + {"abc", Version{}, true}, + } + + for _, tt := range tests { + v, err := Parse(tt.input) + if tt.hasError { + assert.Error(t, err, "expected error for input: %q", tt.input) + } else { + assert.NoError(t, err, "unexpected error for input: %q", tt.input) + assert.Equal(t, tt.expected, v, "parsed version mismatch for input: %q", tt.input) + } + } +} + +func TestVersionCompare(t *testing.T) { + tests := []struct { + v1 Version + v2 Version + expected int // -1 = v1 older, 0 = equal, 1 = v1 newer + }{ + {Version{1, 0, 0}, Version{1, 0, 0}, 0}, + {Version{1, 2, 3}, Version{1, 2, 3}, 0}, + {Version{2, 0, 0}, Version{1, 9, 9}, 1}, + {Version{1, 10, 0}, Version{1, 9, 9}, 1}, + {Version{1, 2, 5}, Version{1, 2, 3}, 1}, + {Version{1, 0, 0}, Version{2, 0, 0}, -1}, + {Version{1, 2, 0}, Version{1, 3, 0}, -1}, + {Version{1, 2, 3}, Version{1, 2, 4}, -1}, + } + + for _, tt := range tests { + result := tt.v1.Compare(tt.v2) + assert.Equal(t, tt.expected, result, "Compare(%+v, %+v)", tt.v1, tt.v2) + } +} + +func TestVersionIsNewerThan(t *testing.T) { + assert.True(t, Version{2, 0, 0}.IsNewerThan(Version{1, 9, 9})) + assert.True(t, Version{1, 2, 3}.IsNewerThan(Version{1, 2, 2})) + assert.False(t, Version{1, 2, 3}.IsNewerThan(Version{1, 2, 3})) + assert.False(t, Version{1, 0, 0}.IsNewerThan(Version{1, 1, 0})) +} + +func TestVersionIsOlderThan(t *testing.T) { + assert.True(t, Version{1, 0, 0}.IsOlderThan(Version{1, 1, 0})) + assert.True(t, Version{1, 2, 2}.IsOlderThan(Version{1, 2, 3})) + assert.False(t, Version{1, 2, 3}.IsOlderThan(Version{1, 2, 3})) + assert.False(t, Version{2, 0, 0}.IsOlderThan(Version{1, 9, 9})) +} diff --git a/main.go b/main.go index 538f3612..eeb241ba 100644 --- a/main.go +++ b/main.go @@ -32,7 +32,9 @@ import ( "github.com/GoMudEngine/GoMud/internal/items" "github.com/GoMudEngine/GoMud/internal/keywords" "github.com/GoMudEngine/GoMud/internal/language" + "github.com/GoMudEngine/GoMud/internal/migration" "github.com/GoMudEngine/GoMud/internal/usercommands" + "github.com/GoMudEngine/GoMud/internal/version" "github.com/gorilla/websocket" "github.com/GoMudEngine/GoMud/internal/mapper" @@ -56,6 +58,13 @@ import ( textLang "golang.org/x/text/language" ) +// Version of the binary +// Should be kept in lockstep with github releases +// When updating this version: +// 1. Expect to update the github release version +// 2. Consider whether any migration code is needed for breaking changes, particularly in datafiles (see internal/migration) +const VERSION = "0.9.1" + var ( sigChan = make(chan os.Signal, 1) workerShutdownChan = make(chan bool, 1) @@ -91,11 +100,24 @@ func main() { os.Getenv(`LOG_NOCOLOR`) == ``, ) - flags.HandleFlags() + flags.HandleFlags(VERSION) configs.ReloadConfig() c := configs.GetConfig() + lastKnownVersion, err := version.Parse(string(configs.GetServerConfig().CurrentVersion)) + if err != nil { + mudlog.Error("Versioning", "error", err) + os.Exit(1) + } + + currentVersion, _ := version.Parse(VERSION) + + if err = migration.Run(lastKnownVersion, currentVersion); err != nil { + mudlog.Error("migration.Run()", "error", err) + os.Exit(1) + } + // Default i18n localize folders if len(c.Translation.LanguagePaths) == 0 { c.Translation.LanguagePaths = []string{ @@ -106,12 +128,19 @@ func main() { mudlog.Info(`========================`) // - mudlog.Info(` ___ ____ _______ `) - mudlog.Info(` | \/ | | | | _ \ `) - mudlog.Info(` | . . | | | | | | | `) - mudlog.Info(` | |\/| | | | | | | | `) - mudlog.Info(` | | | | |_| | |/ / `) - mudlog.Info(` \_| |_/\___/|___/ `) + mudlog.Info(` _____ `) + mudlog.Info(` / ____| `) + mudlog.Info(`| | __ ___ `) + mudlog.Info(`| | |_ |/ _ \ `) + mudlog.Info(`| |__| | (_) | `) + mudlog.Info(` \_____|\___/ `) + mudlog.Info(` __ __ _ `) + mudlog.Info(`| \/ | | |`) + mudlog.Info(`| \ / |_ _ __| |`) + mudlog.Info(`| |\/| | | | |/ _' |`) + mudlog.Info(`| | | | |_| | (_| |`) + mudlog.Info(`|_| |_|\__,_|\__,_|`) + // mudlog.Info(`========================`) //