diff --git a/internal/buffs/buffs.go b/internal/buffs/buffs.go index 084d7902b..443cc211a 100644 --- a/internal/buffs/buffs.go +++ b/internal/buffs/buffs.go @@ -1,6 +1,9 @@ package buffs import ( + "maps" + "slices" + "github.com/GoMudEngine/GoMud/internal/mudlog" ) @@ -48,6 +51,28 @@ func New() Buffs { } } +func (bs Buffs) Clone() Buffs { + cloned := Buffs{ + List: make([]*Buff, len(bs.List)), + buffFlags: make(map[Flag][]int, len(bs.buffFlags)), + buffIds: maps.Clone(bs.buffIds), + } + + for i, buff := range bs.List { + if buff == nil { + continue + } + buffCopy := *buff + cloned.List[i] = &buffCopy + } + + for flag, indexes := range bs.buffFlags { + cloned.buffFlags[flag] = slices.Clone(indexes) + } + + return cloned +} + func (bs *Buffs) Validate(forceRebuild ...bool) { if bs.buffFlags == nil { bs.buffFlags = make(map[Flag][]int) diff --git a/internal/characters/character.go b/internal/characters/character.go index b3010ca00..8c664dc7d 100644 --- a/internal/characters/character.go +++ b/internal/characters/character.go @@ -137,6 +137,62 @@ func New() *Character { } } +func (c *Character) Clone() *Character { + if c == nil { + return nil + } + + cloned := *c + cloned.Adjectives = slices.Clone(c.Adjectives) + cloned.Shop = c.Shop.Clone() + cloned.SpellBook = maps.Clone(c.SpellBook) + cloned.CharmedMobs = slices.Clone(c.CharmedMobs) + cloned.Items = cloneItems(c.Items) + cloned.Buffs = c.Buffs.Clone() + cloned.Equipment = c.Equipment.Clone() + cloned.Skills = maps.Clone(c.Skills) + cloned.Cooldowns = c.Cooldowns.Clone() + cloned.Settings = maps.Clone(c.Settings) + cloned.QuestProgress = maps.Clone(c.QuestProgress) + cloned.KeyRing = maps.Clone(c.KeyRing) + cloned.KD = c.KD.Clone() + cloned.MiscData = maps.Clone(c.MiscData) + cloned.MobMastery = c.MobMastery.Clone() + cloned.Pet = c.Pet.Clone() + cloned.Timers = maps.Clone(c.Timers) + cloned.ZonesVisited = cloneZonesVisited(c.ZonesVisited) + cloned.roomHistory = slices.Clone(c.roomHistory) + cloned.PlayerDamage = maps.Clone(c.PlayerDamage) + cloned.permaBuffIds = slices.Clone(c.permaBuffIds) + + if c.Charmed != nil { + charmed := *c.Charmed + cloned.Charmed = &charmed + } + + return &cloned +} + +func cloneItems(itemList []items.Item) []items.Item { + cloned := slices.Clone(itemList) + for i := range cloned { + cloned[i] = cloned[i].Clone() + } + return cloned +} + +func cloneZonesVisited(zones map[string]RoomBitset) map[string]RoomBitset { + if zones == nil { + return nil + } + + cloned := make(map[string]RoomBitset, len(zones)) + for zone, rooms := range zones { + cloned[zone] = rooms.Clone() + } + return cloned +} + // returns description unless description is a hash // which points to another description location. func (c *Character) GetDescription() string { diff --git a/internal/characters/clone_test.go b/internal/characters/clone_test.go new file mode 100644 index 000000000..c5ed69f72 --- /dev/null +++ b/internal/characters/clone_test.go @@ -0,0 +1,56 @@ +package characters + +import ( + "testing" + + "github.com/GoMudEngine/GoMud/internal/items" +) + +func TestCharacterCloneOwnsNestedMutableState(t *testing.T) { + original := New() + original.Shop = Shop{{ItemId: 4001, Quantity: 1}} + original.Cooldowns = Cooldowns{"cast": 3} + original.KD = KDStats{ + Kills: map[int]int{101: 2}, + PlayerKills: map[string]int{"1:player": 1}, + PlayerDeaths: map[string]int{"2:player": 1}, + } + original.MobMastery = MobMasteries{Tame: map[int]int{201: 4}} + original.ZonesVisited = map[string]RoomBitset{"start": {1: 1}} + original.Equipment.Weapon = items.Item{ItemId: 3001, Adjectives: []string{"old"}} + + cloned := original.Clone() + cloned.Shop[0].Quantity = 5 + cloned.Cooldowns["cast"] = 9 + cloned.KD.Kills[101] = 7 + cloned.KD.PlayerKills["1:player"] = 8 + cloned.KD.PlayerDeaths["2:player"] = 9 + cloned.MobMastery.Tame[201] = 6 + cloned.ZonesVisited["start"].Set(130) + cloned.Equipment.Weapon.Adjectives[0] = "new" + + if original.Shop[0].Quantity != 1 { + t.Fatalf("original shop quantity = %d, want 1", original.Shop[0].Quantity) + } + if original.Cooldowns["cast"] != 3 { + t.Fatalf("original cooldown = %d, want 3", original.Cooldowns["cast"]) + } + if original.KD.Kills[101] != 2 { + t.Fatalf("original mob kills = %d, want 2", original.KD.Kills[101]) + } + if original.KD.PlayerKills["1:player"] != 1 { + t.Fatalf("original player kills = %d, want 1", original.KD.PlayerKills["1:player"]) + } + if original.KD.PlayerDeaths["2:player"] != 1 { + t.Fatalf("original player deaths = %d, want 1", original.KD.PlayerDeaths["2:player"]) + } + if original.MobMastery.Tame[201] != 4 { + t.Fatalf("original tame mastery = %d, want 4", original.MobMastery.Tame[201]) + } + if original.ZonesVisited["start"].Has(130) { + t.Fatal("original zone visit bitset changed after mutating clone") + } + if original.Equipment.Weapon.Adjectives[0] != "old" { + t.Fatalf("original weapon adjective = %q, want old", original.Equipment.Weapon.Adjectives[0]) + } +} diff --git a/internal/characters/cooldowns.go b/internal/characters/cooldowns.go index 9e43a94f0..7c14250b5 100644 --- a/internal/characters/cooldowns.go +++ b/internal/characters/cooldowns.go @@ -1,11 +1,17 @@ package characters import ( + "maps" + "github.com/GoMudEngine/GoMud/internal/gametime" ) type Cooldowns map[string]int +func (cd Cooldowns) Clone() Cooldowns { + return maps.Clone(cd) +} + func (cd Cooldowns) RoundTick() { for trackingTag := range cd { cd[trackingTag] = cd[trackingTag] - 1 diff --git a/internal/characters/kdstats.go b/internal/characters/kdstats.go index 1e2d5e490..3ccff4430 100644 --- a/internal/characters/kdstats.go +++ b/internal/characters/kdstats.go @@ -1,6 +1,9 @@ package characters -import "fmt" +import ( + "fmt" + "maps" +) type KDStats struct { TotalKills int `json:"totalkills,omitempty"` // Quick tally of kills @@ -13,6 +16,13 @@ type KDStats struct { TotalPvpDeaths int `json:"totalpvpdeaths,omitempty"` // Quick tally of pvp deaths } +func (kd KDStats) Clone() KDStats { + kd.Kills = maps.Clone(kd.Kills) + kd.PlayerKills = maps.Clone(kd.PlayerKills) + kd.PlayerDeaths = maps.Clone(kd.PlayerDeaths) + return kd +} + func (kd *KDStats) GetMobKDRatio() float64 { if kd.TotalDeaths == 0 { return float64(kd.TotalKills) diff --git a/internal/characters/mobmastery.go b/internal/characters/mobmastery.go index ec3f3f304..2b93b698c 100644 --- a/internal/characters/mobmastery.go +++ b/internal/characters/mobmastery.go @@ -1,9 +1,16 @@ package characters +import "maps" + type MobMasteries struct { Tame map[int]int `json:"tame,omitempty"` // mobId to proficiency } +func (m MobMasteries) Clone() MobMasteries { + m.Tame = maps.Clone(m.Tame) + return m +} + // // // // // // // // // // // // // Tame related // // // // // // // // // // // // diff --git a/internal/characters/roombitset.go b/internal/characters/roombitset.go index f9a653eb7..0de305dd7 100644 --- a/internal/characters/roombitset.go +++ b/internal/characters/roombitset.go @@ -2,6 +2,7 @@ package characters import ( "fmt" + "maps" "math/bits" "strconv" @@ -18,6 +19,10 @@ import ( // in character save files. type RoomBitset map[uint16]uint64 +func (rb RoomBitset) Clone() RoomBitset { + return maps.Clone(rb) +} + // Set marks a room as visited. Room IDs must be positive; non-positive IDs // are silently ignored because they represent special sentinel values (e.g. // -1 for the character-creation room, 0 for StartRoomIdAlias) that are diff --git a/internal/characters/shop.go b/internal/characters/shop.go index 45049e250..6cb31c8ae 100644 --- a/internal/characters/shop.go +++ b/internal/characters/shop.go @@ -29,6 +29,18 @@ type ShopItem struct { lastRestockRound uint64 // When was the last time an item was restocked? } +func (s Shop) Clone() Shop { + cloned := slices.Clone(s) + for i := range cloned { + cloned[i] = cloned[i].Clone() + } + return cloned +} + +func (si ShopItem) Clone() ShopItem { + return si +} + func (s *Shop) Restock() bool { if len(*s) < 1 { diff --git a/internal/characters/worn.go b/internal/characters/worn.go index 745a20482..9d50eb57d 100644 --- a/internal/characters/worn.go +++ b/internal/characters/worn.go @@ -15,6 +15,20 @@ type Worn struct { Feet items.Item `yaml:"feet,omitempty"` } +func (w Worn) Clone() Worn { + w.Weapon = w.Weapon.Clone() + w.Offhand = w.Offhand.Clone() + w.Head = w.Head.Clone() + w.Neck = w.Neck.Clone() + w.Body = w.Body.Clone() + w.Belt = w.Belt.Clone() + w.Gloves = w.Gloves.Clone() + w.Ring = w.Ring.Clone() + w.Legs = w.Legs.Clone() + w.Feet = w.Feet.Clone() + return w +} + func (w *Worn) StatMod(stat ...string) int { return w.Weapon.StatMod(stat...) + diff --git a/internal/items/clone_test.go b/internal/items/clone_test.go new file mode 100644 index 000000000..2aa60fef0 --- /dev/null +++ b/internal/items/clone_test.go @@ -0,0 +1,43 @@ +package items + +import ( + "testing" + + "github.com/GoMudEngine/GoMud/internal/statmods" +) + +func TestItemCloneOwnsOverrideSpec(t *testing.T) { + original := Item{ + ItemId: 3001, + Adjectives: []string{"old"}, + Spec: &ItemSpec{ + BuffIds: []int{1}, + WornBuffIds: []int{2}, + Damage: Damage{CritBuffIds: []int{3}}, + StatMods: statmods.StatMods{"strength": 4}, + }, + } + + cloned := original.Clone() + cloned.Adjectives[0] = "new" + cloned.Spec.BuffIds[0] = 10 + cloned.Spec.WornBuffIds[0] = 20 + cloned.Spec.Damage.CritBuffIds[0] = 30 + cloned.Spec.StatMods["strength"] = 40 + + if original.Adjectives[0] != "old" { + t.Fatalf("original adjective = %q, want old", original.Adjectives[0]) + } + if original.Spec.BuffIds[0] != 1 { + t.Fatalf("original buff id = %d, want 1", original.Spec.BuffIds[0]) + } + if original.Spec.WornBuffIds[0] != 2 { + t.Fatalf("original worn buff id = %d, want 2", original.Spec.WornBuffIds[0]) + } + if original.Spec.Damage.CritBuffIds[0] != 3 { + t.Fatalf("original crit buff id = %d, want 3", original.Spec.Damage.CritBuffIds[0]) + } + if original.Spec.StatMods["strength"] != 4 { + t.Fatalf("original strength stat mod = %d, want 4", original.Spec.StatMods["strength"]) + } +} diff --git a/internal/items/items.go b/internal/items/items.go index cb5a3bc96..0b8de2b73 100644 --- a/internal/items/items.go +++ b/internal/items/items.go @@ -2,6 +2,8 @@ package items import ( "fmt" + "maps" + "slices" "strconv" "strings" "unicode" @@ -65,6 +67,18 @@ func New(itemId int) Item { return newItm } +func (i Item) Clone() Item { + i.Adjectives = slices.Clone(i.Adjectives) + i.tempDataStore = maps.Clone(i.tempDataStore) + + if i.Spec != nil { + spec := i.Spec.Clone() + i.Spec = &spec + } + + return i +} + func (i *Item) GetScript() string { return i.GetSpec().GetScript() } diff --git a/internal/items/itemspec.go b/internal/items/itemspec.go index 4a3fee286..5fbd31795 100644 --- a/internal/items/itemspec.go +++ b/internal/items/itemspec.go @@ -2,8 +2,10 @@ package items import ( "fmt" + "maps" "math" "os" + "slices" "strconv" "strings" "time" @@ -188,6 +190,11 @@ type Damage struct { BonusDamage int `yaml:"bonusdamage,omitempty"` // flat damage bonus, so for example 1d6+1 } +func (d Damage) Clone() Damage { + d.CritBuffIds = slices.Clone(d.CritBuffIds) + return d +} + type ItemMessage string // Attack messages @@ -220,6 +227,14 @@ type ItemSpec struct { KeyLockId string `yaml:"keylockid,omitempty"` // Example: `778-north` - If it's a key, what lock does it open? roomid-exitname etc. } +func (i ItemSpec) Clone() ItemSpec { + i.BuffIds = slices.Clone(i.BuffIds) + i.WornBuffIds = slices.Clone(i.WornBuffIds) + i.Damage = i.Damage.Clone() + i.StatMods = maps.Clone(i.StatMods) + return i +} + func (i Element) String() string { return string(i) } diff --git a/internal/pets/clone_test.go b/internal/pets/clone_test.go new file mode 100644 index 000000000..265a36cfa --- /dev/null +++ b/internal/pets/clone_test.go @@ -0,0 +1,43 @@ +package pets + +import ( + "testing" + + "github.com/GoMudEngine/GoMud/internal/items" + "github.com/GoMudEngine/GoMud/internal/statmods" +) + +func TestPetCloneOwnsInventoryAndAbilities(t *testing.T) { + original := Pet{ + Level: 1, + Items: []items.Item{ + {ItemId: 3001, Adjectives: []string{"old"}}, + }, + Abilities: []PetAbility{ + { + Damage: items.Damage{CritBuffIds: []int{1}}, + StatMods: statmods.StatMods{"speed": 2}, + BuffIds: []int{3}, + }, + }, + } + + cloned := original.Clone() + cloned.Items[0].Adjectives[0] = "new" + cloned.Abilities[0].Damage.CritBuffIds[0] = 10 + cloned.Abilities[0].StatMods["speed"] = 20 + cloned.Abilities[0].BuffIds[0] = 30 + + if original.Items[0].Adjectives[0] != "old" { + t.Fatalf("original item adjective = %q, want old", original.Items[0].Adjectives[0]) + } + if original.Abilities[0].Damage.CritBuffIds[0] != 1 { + t.Fatalf("original crit buff id = %d, want 1", original.Abilities[0].Damage.CritBuffIds[0]) + } + if original.Abilities[0].StatMods["speed"] != 2 { + t.Fatalf("original speed stat mod = %d, want 2", original.Abilities[0].StatMods["speed"]) + } + if original.Abilities[0].BuffIds[0] != 3 { + t.Fatalf("original buff id = %d, want 3", original.Abilities[0].BuffIds[0]) + } +} diff --git a/internal/pets/petability.go b/internal/pets/petability.go index 6424cf088..3b2921d7a 100644 --- a/internal/pets/petability.go +++ b/internal/pets/petability.go @@ -1,6 +1,9 @@ package pets import ( + "maps" + "slices" + "github.com/GoMudEngine/GoMud/internal/items" "github.com/GoMudEngine/GoMud/internal/statmods" ) @@ -13,3 +16,10 @@ type PetAbility struct { BuffIds []int `yaml:"buffids,omitempty"` Capacity int `yaml:"capacity,omitempty"` } + +func (pa PetAbility) Clone() PetAbility { + pa.Damage = pa.Damage.Clone() + pa.StatMods = maps.Clone(pa.StatMods) + pa.BuffIds = slices.Clone(pa.BuffIds) + return pa +} diff --git a/internal/pets/pets.go b/internal/pets/pets.go index 058468ee7..60da92f70 100644 --- a/internal/pets/pets.go +++ b/internal/pets/pets.go @@ -3,6 +3,7 @@ package pets import ( "fmt" "os" + "slices" "time" "github.com/GoMudEngine/GoMud/internal/buffs" @@ -32,6 +33,25 @@ type Pet struct { cachedLevel int `yaml:"-"` // level when cache was set } +func (p Pet) Clone() Pet { + p.Items = cloneItems(p.Items) + p.Abilities = slices.Clone(p.Abilities) + for i := range p.Abilities { + p.Abilities[i] = p.Abilities[i].Clone() + } + p.cachedAbility = nil + p.cachedLevel = 0 + return p +} + +func cloneItems(itemList []items.Item) []items.Item { + cloned := slices.Clone(itemList) + for i := range cloned { + cloned[i] = cloned[i].Clone() + } + return cloned +} + var ( petTypes = map[string]*Pet{} ) diff --git a/internal/users/clone_test.go b/internal/users/clone_test.go new file mode 100644 index 000000000..c1be7f7a3 --- /dev/null +++ b/internal/users/clone_test.go @@ -0,0 +1,35 @@ +package users + +import "testing" + +func TestUserRecordCloneOwnsUserMutableState(t *testing.T) { + original := NewUserRecord(1, 100) + original.Macros = map[string]string{"a": "look"} + original.Aliases = map[string]string{"l": "look"} + original.ConfigOptions = map[string]any{"theme": "dark"} + original.TipsComplete = map[string]bool{"movement": true} + original.EventLog = UserLog{{Category: "test", What: "original"}} + + cloned := original.Clone() + cloned.Macros["a"] = "inventory" + cloned.Aliases["l"] = "listen" + cloned.ConfigOptions["theme"] = "light" + cloned.TipsComplete["movement"] = false + cloned.EventLog[0].What = "clone" + + if original.Macros["a"] != "look" { + t.Fatalf("original macro = %q, want look", original.Macros["a"]) + } + if original.Aliases["l"] != "look" { + t.Fatalf("original alias = %q, want look", original.Aliases["l"]) + } + if original.ConfigOptions["theme"] != "dark" { + t.Fatalf("original theme = %q, want dark", original.ConfigOptions["theme"]) + } + if !original.TipsComplete["movement"] { + t.Fatal("original tip completion changed after mutating clone") + } + if original.EventLog[0].What != "original" { + t.Fatalf("original event log entry = %q, want original", original.EventLog[0].What) + } +} diff --git a/internal/users/userlog.go b/internal/users/userlog.go index ee11a2865..b88e56fe5 100644 --- a/internal/users/userlog.go +++ b/internal/users/userlog.go @@ -1,6 +1,7 @@ package users import ( + "slices" "time" "github.com/GoMudEngine/GoMud/internal/util" @@ -20,6 +21,10 @@ type UserLogEntry struct { type UserLog []UserLogEntry +func (ul UserLog) Clone() UserLog { + return slices.Clone(ul) +} + func (ul *UserLog) Add(cat string, message string) { if LogMinAllocation < 1 { // disables log if <1 diff --git a/internal/users/userrecord.go b/internal/users/userrecord.go index cb9e19c15..ecc9fc1a1 100644 --- a/internal/users/userrecord.go +++ b/internal/users/userrecord.go @@ -3,6 +3,7 @@ package users import ( "crypto/md5" "fmt" + "maps" "math" "math/big" "strings" @@ -82,6 +83,22 @@ func NewUserRecord(userId int, connectionId uint64) *UserRecord { return u } +func (u *UserRecord) Clone() UserRecord { + cloned := *u + cloned.Macros = maps.Clone(u.Macros) + cloned.Aliases = maps.Clone(u.Aliases) + if u.Character == nil { + cloned.Character = characters.New() + } else { + cloned.Character = u.Character.Clone() + } + cloned.ConfigOptions = maps.Clone(u.ConfigOptions) + cloned.TipsComplete = maps.Clone(u.TipsComplete) + cloned.EventLog = u.EventLog.Clone() + cloned.tempDataStore = maps.Clone(u.tempDataStore) + return cloned +} + func (u *UserRecord) ClientSettings() connections.ClientSettings { return connections.GetClientSettings(u.connectionId) }