aboutsummaryrefslogtreecommitdiff
path: root/internal/world
diff options
context:
space:
mode:
Diffstat (limited to 'internal/world')
-rw-r--r--internal/world/fix_regressions_test.go61
-rw-r--r--internal/world/mob.go26
-rw-r--r--internal/world/mob_rekey_test.go48
-rw-r--r--internal/world/objects.go15
-rw-r--r--internal/world/room.go4
-rw-r--r--internal/world/room_test.go31
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)
+ }
+}