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(`========================`)
//