Repository: evennia/evennia

Issue:
[BUG] `rate` change calculations for traits in the Traits contrib break on reload
#### Describe the bug
A game reload will reset the "last updated" value for traits and thus stop all rate changes for all traits on all objects. The rate changes will only begin again when something else changes the current value for that specific trait, completely losing the time in between the reload and that value change.

#### To Reproduce
Steps to reproduce the behavior:
1. Add the traits handler from the contrib to any typeclass according to the instructions
2. Add a Counter or Gauge trait to an object or character with a non-zero rate
3. Change the current value and verify it is changing
4. `reload` the game server
5. Check the value a few times over the next ten seconds, minute, however long you want - the value will not change
6. Manually change the current value - it will begin changing again.

#### Expected behavior
The traits should actually remember their last change for the rate so that they give the correct updated value, even with a reload in the interim.

#### Environment, Evennia version, OS etc
    Evennia 2.3.0
    Windows 10
    Python 3.10



Discussion:
The issue does not appear to require a value update, as demonstrated in the screenshot, but the number remains static until the trait is referenced again.

```py
    @lazy_property
    def traits(self):
        return TraitHandler(self)

    @property
    def test(self):
        return self.traits.get("test")

    def at_object_creation(self):
        self.traits.add(
            "test", "Test", trait_type="counter", base=1, min=0, max=100, rate=1
        )
```

![image](https://github.com/evennia/evennia/assets/73198594/de0328be-251c-444c-943c-5aab23140a8a)




Resolving patch:
diff --git a/evennia/contrib/rpg/traits/traits.py b/evennia/contrib/rpg/traits/traits.py
--- a/evennia/contrib/rpg/traits/traits.py
+++ b/evennia/contrib/rpg/traits/traits.py
@@ -571,12 +571,16 @@ def __init__(self, obj, db_attribute_key="traits", db_attribute_category="traits
         # initialize any
         # Note that .trait_data retains the connection to the database, meaning every
         # update we do to .trait_data automatically syncs with database.
-        self.trait_data = obj.attributes.get(db_attribute_key, category=db_attribute_category)
+        self.trait_data = obj.attributes.get(
+            db_attribute_key, category=db_attribute_category
+        )
         if self.trait_data is None:
             # no existing storage; initialize it, we then have to fetch it again
             # to retain the db connection
             obj.attributes.add(db_attribute_key, {}, category=db_attribute_category)
-            self.trait_data = obj.attributes.get(db_attribute_key, category=db_attribute_category)
+            self.trait_data = obj.attributes.get(
+                db_attribute_key, category=db_attribute_category
+            )
         self._cache = {}
 
     def __len__(self):
@@ -595,7 +599,9 @@ def __setattr__(self, trait_key, value):
             _SA(self, trait_key, value)
         else:
             trait_cls = self._get_trait_class(trait_key=trait_key)
-            valid_keys = list_to_string(list(trait_cls.default_keys.keys()), endsep="or")
+            valid_keys = list_to_string(
+                list(trait_cls.default_keys.keys()), endsep="or"
+            )
             raise TraitException(
                 f"Trait object not settable directly. Assign to {trait_key}.{valid_keys}."
             )
@@ -627,7 +633,9 @@ def _get_trait_class(self, trait_type=None, trait_key=None):
             try:
                 trait_type = self.trait_data[trait_key]["trait_type"]
             except KeyError:
-                raise TraitException(f"Trait class for Trait {trait_key} could not be found.")
+                raise TraitException(
+                    f"Trait class for Trait {trait_key} could not be found."
+                )
         try:
             return _TRAIT_CLASSES[trait_type]
         except KeyError:
@@ -657,11 +665,18 @@ def get(self, trait_key):
         if trait is None and trait_key in self.trait_data:
             trait_type = self.trait_data[trait_key]["trait_type"]
             trait_cls = self._get_trait_class(trait_type)
-            trait = self._cache[trait_key] = trait_cls(_GA(self, "trait_data")[trait_key])
+            trait = self._cache[trait_key] = trait_cls(
+                _GA(self, "trait_data")[trait_key]
+            )
         return trait
 
     def add(
-        self, trait_key, name=None, trait_type=DEFAULT_TRAIT_TYPE, force=True, **trait_properties
+        self,
+        trait_key,
+        name=None,
+        trait_type=DEFAULT_TRAIT_TYPE,
+        force=True,
+        **trait_properties,
     ):
         """
         Create a new Trait and add it to the handler.
@@ -748,7 +763,9 @@ class Character(DefaultCharacter):
 
     """
 
-    def __init__(self, name=None, trait_type=DEFAULT_TRAIT_TYPE, force=True, **trait_properties):
+    def __init__(
+        self, name=None, trait_type=DEFAULT_TRAIT_TYPE, force=True, **trait_properties
+    ):
         """
         Initialize a TraitField. Mimics TraitHandler.add input except no `trait_key`.
 
@@ -767,7 +784,9 @@ def __init__(self, name=None, trait_type=DEFAULT_TRAIT_TYPE, force=True, **trait
         """
         self._traithandler_name = trait_properties.pop("traithandler_name", "traits")
 
-        trait_properties.update({"name": name, "trait_type": trait_type, "force": force})
+        trait_properties.update(
+            {"name": name, "trait_type": trait_type, "force": force}
+        )
         self._trait_properties = trait_properties
         self._cache = {}
 
@@ -807,7 +826,9 @@ def __get__(self, instance, owner):
             if trait is None:
                 # initialize the trait
                 traithandler.add(self._trait_key, **self._trait_properties)
-                trait = traithandler.get(self._trait_key)  # caches it in the traithandler
+                trait = traithandler.get(
+                    self._trait_key
+                )  # caches it in the traithandler
             self._cache[instance] = trait
         return self._cache[instance]
 
@@ -915,13 +936,21 @@ def _raise_err(unset_required):
 
         if MandatoryTraitKey in unset_defaults.values():
             # we have one or more unset keys that was mandatory
-            _raise_err([key for key, value in unset_defaults.items() if value == MandatoryTraitKey])
+            _raise_err(
+                [
+                    key
+                    for key, value in unset_defaults.items()
+                    if value == MandatoryTraitKey
+                ]
+            )
         # apply the default values
         trait_data.update(unset_defaults)
 
         if not cls.allow_extra_properties:
             # don't allow any extra properties - remove the extra data
-            for key in (key for key in inp.difference(req) if key not in ("name", "trait_type")):
+            for key in (
+                key for key in inp.difference(req) if key not in ("name", "trait_type")
+            ):
                 del trait_data[key]
 
         return trait_data
@@ -945,7 +974,12 @@ def __delitem__(self, key):
 
     def __getattr__(self, key):
         """Access extra parameters as attributes."""
-        if key in ("default_keys", "data_default", "trait_type", "allow_extra_properties"):
+        if key in (
+            "default_keys",
+            "data_default",
+            "trait_type",
+            "allow_extra_properties",
+        ):
             return _GA(self, key)
         try:
             return self._data[key]
@@ -1276,7 +1310,7 @@ def validate_input(cls, trait_data):
                 )
         # set up rate
         if trait_data["rate"] != 0:
-            trait_data["last_update"] = time()
+            trait_data["last_update"] = trait_data.get("last_update", time())
         else:
             trait_data["last_update"] = None
         return trait_data
@@ -1310,7 +1344,8 @@ def _passed_ratetarget(self, value):
         """Check if we passed the ratetarget in either direction."""
         ratetarget = self._data["ratetarget"]
         return ratetarget is not None and (
-            (self.rate < 0 and value <= ratetarget) or (self.rate > 0 and value >= ratetarget)
+            (self.rate < 0 and value <= ratetarget)
+            or (self.rate > 0 and value >= ratetarget)
         )
 
     def _stop_timer(self):
@@ -1435,7 +1470,9 @@ def current(self):
     @current.setter
     def current(self, value):
         if type(value) in (int, float):
-            self._data["current"] = self._check_and_start_timer(self._enforce_boundaries(value))
+            self._data["current"] = self._check_and_start_timer(
+                self._enforce_boundaries(value)
+            )
 
     @current.deleter
     def current(self):
@@ -1552,6 +1589,7 @@ def _update_current(self, current):
         rate = self.rate
         if rate != 0 and self._data["last_update"] is not None:
             now = time()
+
             tdiff = now - self._data["last_update"]
             current += rate * tdiff
             value = current
@@ -1657,13 +1695,17 @@ def max(self):
     def current(self):
         """The `current` value of the gauge."""
         return self._update_current(
-            self._enforce_boundaries(self._data.get("current", (self.base + self.mod) * self.mult))
+            self._enforce_boundaries(
+                self._data.get("current", (self.base + self.mod) * self.mult)
+            )
         )
 
     @current.setter
     def current(self, value):
         if type(value) in (int, float):
-            self._data["current"] = self._check_and_start_timer(self._enforce_boundaries(value))
+            self._data["current"] = self._check_and_start_timer(
+                self._enforce_boundaries(value)
+            )
 
     @current.deleter
     def current(self):


Test patch: