diff options
Diffstat (limited to 'internal/world')
| -rw-r--r-- | internal/world/fix_regressions_test.go | 61 | ||||
| -rw-r--r-- | internal/world/mob.go | 26 | ||||
| -rw-r--r-- | internal/world/mob_rekey_test.go | 48 | ||||
| -rw-r--r-- | internal/world/objects.go | 15 | ||||
| -rw-r--r-- | internal/world/room.go | 4 | ||||
| -rw-r--r-- | internal/world/room_test.go | 31 |
6 files changed, 177 insertions, 8 deletions
diff --git a/internal/world/fix_regressions_test.go b/internal/world/fix_regressions_test.go index f2ef795..770d2ef 100644 --- a/internal/world/fix_regressions_test.go +++ b/internal/world/fix_regressions_test.go @@ -151,3 +151,64 @@ func TestRekeyRoomsRemapsHomeRoomID(t *testing.T) { t.Fatalf("HomeRoomID after rekey = %d, want 3", moved.HomeRoomID) } } + +// TestSetObjPropsMatchHomeRoomNotCurrentRoom: content edits pass the declaring +// room, so property setters must match on HomeRoomID rather than the object's +// current RoomID (which differs while it has wandered away). +func TestSetObjPropsMatchHomeRoomNotCurrentRoom(t *testing.T) { + w := New(t.TempDir()) + w.objStates[w.ObjStateKey(2, "anvil", 0)] = &ObjState{ + RoomID: 2, + HomeRoomID: 1, + DefID: "anvil", + Index: 0, + } + w.SetObjName(1, "anvil", "iron anvil") + w.SetObjAliases(1, "anvil", []string{"workbench"}) + w.SetObjWander(1, "anvil", []int{2, 3}, 4) + w.SetObjDepleteTimer(1, "anvil", 7) + + st := w.GetObjState(2, "anvil", 0) + if st.Name != "iron anvil" { + t.Fatalf("name not applied: %+v", st) + } + if len(st.Aliases) != 1 || st.Aliases[0] != "workbench" { + t.Fatalf("aliases not applied: %+v", st) + } + if len(st.WanderRooms) != 2 || st.WanderInterval != 4 { + t.Fatalf("wander not applied: %+v", st) + } + if st.SharedMax != 7 { + t.Fatalf("deplete timer not applied: %+v", st) + } +} + +// TestTickObjWanderSkipsOccupiedDestination: a wandering instance whose +// destination key is already occupied must not evict the occupant for the +// process lifetime. +func TestTickObjWanderSkipsOccupiedDestination(t *testing.T) { + w := New(t.TempDir()) + w.AddRoomPath(1, "room-1") + w.AddRoomPath(2, "room-2") + w.objStates[w.ObjStateKey(2, "anvil", 0)] = &ObjState{ + RoomID: 2, + HomeRoomID: 2, + DefID: "anvil", + Index: 0, + } + w.objStates[w.ObjStateKey(1, "anvil", 0)] = &ObjState{ + RoomID: 1, + HomeRoomID: 1, + DefID: "anvil", + Index: 0, + WanderRooms: []int{2}, + WanderInterval: 1, + } + w.TickObjWander() + if w.GetObjState(2, "anvil", 0) == nil { + t.Fatal("destination occupant was evicted by a colliding wanderer") + } + if w.GetObjState(1, "anvil", 0) == nil { + t.Fatal("wanderer was lost instead of skipping the occupied destination") + } +} diff --git a/internal/world/mob.go b/internal/world/mob.go index 4441108..10759f8 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -241,6 +241,9 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in if def.Combat != nil { aggressive = def.Combat.Aggressive respawnTicks = def.Combat.RespawnTicks + if respawnTicks <= 0 { + respawnTicks = 30 + } attackType = append([]string(nil), def.Combat.AttackTypes...) cs := def.Combat.Stats @@ -723,12 +726,20 @@ func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) { s.mu.Lock() defer s.mu.Unlock() - for i, dw := range defs { + // Instance keys use a per-defID occurrence counter, not the slice index: + // reordering a room's mobs: list must not orphan the old instances and + // spawn duplicates. Two entries of the same defID get suffixes 0 and 1. + declared := make(map[string]bool, len(entries)) + occurrence := make(map[string]int) + for _, dw := range defs { + defID := dw.rm.ID + n := occurrence[defID] + occurrence[defID]++ + instID := fmt.Sprintf("%s_%d_%d", defID, roomID, n) + declared[instID] = true if dw.err != nil { continue } - defID := dw.rm.ID - instID := fmt.Sprintf("%s_%d_%d", defID, roomID, i) if _, exists := s.instances[instID]; exists { continue } @@ -736,6 +747,15 @@ func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) { inst.IdleDescription = pickIdleDescription(dw.def.IdleDescriptions) s.instances[instID] = inst } + // Drop seeded instances this room no longer declares. Trigger-spawned and + // wandered-away instances are never pruned here: a declared mob that has + // wandered keeps its deterministic key, and transient mobs are owned by + // the trigger system. + for id, inst := range s.instances { + if inst.HomeRoomID == roomID && !inst.SpawnedByTrigger && !declared[id] { + delete(s.instances, id) + } + } } // RekeyRooms remaps every live mob instance's RoomID and HomeRoomID per diff --git a/internal/world/mob_rekey_test.go b/internal/world/mob_rekey_test.go index 8118059..af4dd5c 100644 --- a/internal/world/mob_rekey_test.go +++ b/internal/world/mob_rekey_test.go @@ -51,3 +51,51 @@ func TestRekeyRoomsUpdatesMobDespawnRooms(t *testing.T) { t.Fatalf("despawn_rooms = %v, want [3 7]", got) } } + +// TestSeedMobsKeysByDefOccurrenceAndPrunes: reordering a room's mobs list +// must not orphan old instances (spawning duplicates) and removing an entry +// must delete its seeded instance. Keys use a per-defID occurrence counter, so +// the same declaration keeps the same key across reorders. +func TestSeedMobsKeysByDefOccurrenceAndPrunes(t *testing.T) { + s := &MobStore{ + defs: map[string]*MobDef{ + "rat": {ID: "rat", Name: "rat"}, + "bat": {ID: "bat", Name: "bat"}, + }, + instances: map[string]*MobInstance{ + "rat_1_0": {InstanceID: "rat_1_0", DefID: "rat", RoomID: 1, HomeRoomID: 1, HP: 1, MaxHP: 1}, + "bat_1_0": {InstanceID: "bat_1_0", DefID: "bat", RoomID: 1, HomeRoomID: 1, HP: 1, MaxHP: 1}, + }, + } + // Reorder and drop rat: bat keeps its key, rat is pruned, no duplicates. + s.SeedMobs(1, []RoomMob{{ID: "bat"}}) + if s.instances["bat_1_0"] == nil { + t.Fatal("surviving declared mob was dropped") + } + if _, ok := s.instances["rat_1_0"]; ok { + t.Fatal("removed mob was not pruned") + } + if len(s.instances) != 1 { + t.Fatalf("instances = %v, want only bat_1_0", s.instances) + } +} + +func TestSeedMobsReorderDoesNotDuplicate(t *testing.T) { + s := &MobStore{ + defs: map[string]*MobDef{ + "rat": {ID: "rat", Name: "rat"}, + "bat": {ID: "bat", Name: "bat"}, + }, + instances: map[string]*MobInstance{ + "rat_1_0": {InstanceID: "rat_1_0", DefID: "rat", RoomID: 1, HomeRoomID: 1, HP: 1, MaxHP: 1}, + "bat_1_0": {InstanceID: "bat_1_0", DefID: "bat", RoomID: 1, HomeRoomID: 1, HP: 1, MaxHP: 1}, + }, + } + s.SeedMobs(1, []RoomMob{{ID: "bat"}, {ID: "rat"}}) + if len(s.instances) != 2 { + t.Fatalf("reorder changed instance count: %v", s.instances) + } + if s.instances["rat_1_0"] == nil || s.instances["bat_1_0"] == nil { + t.Fatalf("reorder lost a declared mob: %v", s.instances) + } +} diff --git a/internal/world/objects.go b/internal/world/objects.go index 3c004a5..9101fbc 100644 --- a/internal/world/objects.go +++ b/internal/world/objects.go @@ -2,6 +2,7 @@ package world import ( "fmt" + "log" "math/rand" "sort" "strings" @@ -343,7 +344,7 @@ func (w *World) SetObjName(roomID int, defID string, name string) { w.mu.Lock() defer w.mu.Unlock() for _, st := range w.objStates { - if st.RoomID == roomID && st.DefID == defID { + if st.HomeRoomID == roomID && st.DefID == defID { st.Name = name } } @@ -353,7 +354,7 @@ func (w *World) SetObjAliases(roomID int, defID string, aliases []string) { w.mu.Lock() defer w.mu.Unlock() for _, st := range w.objStates { - if st.RoomID == roomID && st.DefID == defID { + if st.HomeRoomID == roomID && st.DefID == defID { st.Aliases = aliases } } @@ -363,7 +364,7 @@ func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval flo w.mu.Lock() defer w.mu.Unlock() for _, st := range w.objStates { - if st.RoomID == roomID && st.DefID == defID { + if st.HomeRoomID == roomID && st.DefID == defID { st.WanderRooms = rooms st.WanderInterval = interval } @@ -374,7 +375,7 @@ func (w *World) SetObjDepleteTimer(roomID int, defID string, max float64) { w.mu.Lock() defer w.mu.Unlock() for _, st := range w.objStates { - if st.RoomID == roomID && st.DefID == defID && st.SharedMax == 0 { + if st.HomeRoomID == roomID && st.DefID == defID && st.SharedMax == 0 { st.SharedMax = max st.SharedTimer = int(max) } @@ -451,8 +452,12 @@ func (w *World) TickObjWander() { for _, m := range moves { oldKey := w.ObjStateKey(m.st.RoomID, m.st.DefID, m.st.Index) fromRoom := m.st.RoomID + newKey := w.ObjStateKey(m.toRoom, m.st.DefID, m.st.Index) + if existing, ok := w.objStates[newKey]; ok && existing != m.st { + log.Printf("skip wander of object %q index %d from room %d to %d: destination key %q is already occupied", m.st.DefID, m.st.Index, fromRoom, m.toRoom, newKey) + continue + } m.st.RoomID = m.toRoom - newKey := w.ObjStateKey(m.st.RoomID, m.st.DefID, m.st.Index) delete(w.objStates, oldKey) w.objStates[newKey] = m.st w.objMoves = append(w.objMoves, ObjMove{ diff --git a/internal/world/room.go b/internal/world/room.go index 9b9bef7..af7a712 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -199,6 +199,8 @@ func (ro RoomObject) MarshalYAML() (interface{}, error) { Talk *behavior.TalkConfig `yaml:"talk,omitempty"` Safespot *object.SafespotConfig `yaml:"safespot,omitempty"` OnLook []behavior.Trigger `yaml:"on_look,omitempty"` + WanderRooms []int `yaml:"wander_rooms,omitempty"` + WanderInterval float64 `yaml:"wander_interval,omitempty"` } def := ro.Local return inlineObj{ @@ -215,6 +217,8 @@ func (ro RoomObject) MarshalYAML() (interface{}, error) { Talk: def.Talk, Safespot: def.Safespot, OnLook: def.OnLook, + WanderRooms: ro.WanderRooms, + WanderInterval: ro.WanderInterval, }, nil } type rawRef struct { diff --git a/internal/world/room_test.go b/internal/world/room_test.go index 855eba5..893f858 100644 --- a/internal/world/room_test.go +++ b/internal/world/room_test.go @@ -6,6 +6,8 @@ import ( "testing" "gopkg.in/yaml.v3" + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/object" ) func writeRoom(t *testing.T, dir string, body string) { @@ -137,3 +139,32 @@ func TestExitDefUnmarshalAlwaysBlocked(t *testing.T) { t.Error("east exit should not have AlwaysBlocked") } } + +func TestRoomObjectMarshalYAMLRoundTripsWander(t *testing.T) { + ro := RoomObject{ + ID: "fence", + WanderRooms: []int{2, 3}, + WanderInterval: 5, + Local: &object.ObjectDef{ + Name: "fence", + Description: behavior.DescList{{Text: "A fence."}}, + }, + } + data, err := yaml.Marshal(ro) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out RoomObject + if err := yaml.Unmarshal(data, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.WanderRooms) != 2 || out.WanderRooms[0] != 2 || out.WanderRooms[1] != 3 { + t.Fatalf("wander_rooms round trip = %v, want [2 3]", out.WanderRooms) + } + if out.WanderInterval != 5 { + t.Fatalf("wander_interval round trip = %v, want 5", out.WanderInterval) + } + if out.Local == nil || out.Local.Name != "fence" { + t.Fatalf("local object lost: %+v", out.Local) + } +} |
